From 0f58d63aef6810e270b9fc50511fcc1857f890f3 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Fri, 2 Oct 2026 16:41:00 +0200 Subject: [PATCH] chore(tests) #81 Add missing tests: functional tests of the UI and the HTTP API, unit tests of every class Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 3 + CHANGELOG.md | 1 + phpunit.xml.dist | 4 + tests/Admin/Field/ContextFieldTest.php | 44 ++++ tests/Admin/Field/EnumFieldTest.php | 44 ++++ tests/Admin/Field/LogLevelFieldTest.php | 44 ++++ tests/Admin/Filter/LogProcessFilterTest.php | 106 ++++++++ .../ProcessExecutionDurationFilterTest.php | 104 ++++++++ tests/App/TestKernel.php | 84 +++++++ tests/App/config/packages.yaml | 98 ++++++++ tests/App/config/routes.yaml | 3 + tests/CleverAgeUiProcessBundleTest.php | 42 ++++ tests/Command/UserCreateCommandTest.php | 162 ++++++++++++ .../Admin/Security/LogoutControllerTest.php | 48 ++++ .../CleverAgeUiProcessExtensionTest.php | 235 ++++++++++++++++++ .../DependencyInjection/ConfigurationTest.php | 161 ++++++++++++ .../Enum/ProcessExecutionStatusTest.php | 39 +++ tests/Entity/Enum/ProcessScheduleTypeTest.php | 39 +++ tests/Entity/LogRecordTest.php | 13 + tests/Entity/ProcessExecutionTest.php | 112 +++++++++ tests/Entity/ProcessScheduleTest.php | 133 ++++++++++ tests/Entity/UserTest.php | 81 ++++++ .../ProcessEventSubscriberTest.php | 191 ++++++++++++++ tests/Form/Type/LaunchTypeTest.php | 169 +++++++++++++ tests/Form/Type/ProcessContextTypeTest.php | 79 ++++++ tests/Functional/FunctionalTestCase.php | 114 +++++++++ tests/Functional/HttpProcessExecuteTest.php | 195 +++++++++++++++ tests/Functional/LaunchActionTest.php | 167 +++++++++++++ tests/Functional/LogRecordCrudTest.php | 134 ++++++++++ tests/Functional/ProcessExecutionCrudTest.php | 181 ++++++++++++++ tests/Functional/ProcessListTest.php | 114 +++++++++ tests/Functional/ProcessScheduleCrudTest.php | 169 +++++++++++++ tests/Functional/SecurityTest.php | 142 +++++++++++ tests/Functional/UserCrudTest.php | 144 +++++++++++ tests/Http/Model/HttpProcessExecutionTest.php | 108 ++++++++ .../HttpProcessExecuteValueResolverTest.php | 157 ++++++++++++ .../ProcessConfigurationsManagerTest.php | 158 ++++++++++++ tests/Manager/ProcessExecutionManagerTest.php | 94 +++++++ .../Message/CronProcessMessageHandlerTest.php | 83 +++++++ tests/Message/CronProcessMessageTest.php | 30 +++ tests/Message/ProcessExecuteHandlerTest.php | 51 ++++ tests/Message/ProcessExecuteMessageTest.php | 39 +++ tests/Migrations/MigrationTestCase.php | 87 +++++++ .../Migrations/Version20231006111525Test.php | 131 ++++++++++ .../Migrations/Version20240729151928Test.php | 72 ++++++ .../Migrations/Version20240730090403Test.php | 46 ++++ .../Migrations/Version20241007134542Test.php | 78 ++++++ .../Migrations/Version20241007152613Test.php | 78 ++++++ .../Migrations/Version20241009075733Test.php | 78 ++++++ .../Handler/DoctrineProcessHandlerTest.php | 143 +++++++++++ tests/Monolog/Handler/ProcessHandlerTest.php | 12 + .../ProcessExecutionRepositoryTest.php | 139 +++++++++++ .../ProcessScheduleRepositoryTest.php | 86 +++++++ tests/Scheduler/CronSchedulerTest.php | 157 ++++++++++++ tests/Twig/Components/BootstrapModalTest.php | 41 +++ .../Twig/Extension/LogLevelExtensionTest.php | 38 +++ tests/Twig/Extension/MD5ExtensionTest.php | 34 +++ .../ProcessExecutionExtensionTest.php | 38 +++ tests/Twig/Extension/ProcessExtensionTest.php | 34 +++ .../Runtime/LogLevelExtensionRuntimeTest.php | 86 +++++++ .../Twig/Runtime/MD5ExtensionRuntimeTest.php | 30 +++ .../ProcessExecutionExtensionRuntimeTest.php | 74 ++++++ .../Runtime/ProcessExtensionRuntimeTest.php | 53 ++++ .../Validator/CronExpressionValidatorTest.php | 85 +++++++ .../EveryExpressionValidatorTest.php | 70 ++++++ .../IsValidProcessCodeValidatorTest.php | 69 +++++ 66 files changed, 5878 insertions(+) create mode 100644 tests/Admin/Field/ContextFieldTest.php create mode 100644 tests/Admin/Field/EnumFieldTest.php create mode 100644 tests/Admin/Field/LogLevelFieldTest.php create mode 100644 tests/Admin/Filter/LogProcessFilterTest.php create mode 100644 tests/Admin/Filter/ProcessExecutionDurationFilterTest.php create mode 100644 tests/App/TestKernel.php create mode 100644 tests/App/config/packages.yaml create mode 100644 tests/App/config/routes.yaml create mode 100644 tests/CleverAgeUiProcessBundleTest.php create mode 100644 tests/Command/UserCreateCommandTest.php create mode 100644 tests/Controller/Admin/Security/LogoutControllerTest.php create mode 100644 tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php create mode 100644 tests/DependencyInjection/ConfigurationTest.php create mode 100644 tests/Entity/Enum/ProcessExecutionStatusTest.php create mode 100644 tests/Entity/Enum/ProcessScheduleTypeTest.php create mode 100644 tests/Entity/ProcessExecutionTest.php create mode 100644 tests/Entity/ProcessScheduleTest.php create mode 100644 tests/Entity/UserTest.php create mode 100644 tests/EventSubscriber/ProcessEventSubscriberTest.php create mode 100644 tests/Form/Type/LaunchTypeTest.php create mode 100644 tests/Form/Type/ProcessContextTypeTest.php create mode 100644 tests/Functional/FunctionalTestCase.php create mode 100644 tests/Functional/HttpProcessExecuteTest.php create mode 100644 tests/Functional/LaunchActionTest.php create mode 100644 tests/Functional/LogRecordCrudTest.php create mode 100644 tests/Functional/ProcessExecutionCrudTest.php create mode 100644 tests/Functional/ProcessListTest.php create mode 100644 tests/Functional/ProcessScheduleCrudTest.php create mode 100644 tests/Functional/SecurityTest.php create mode 100644 tests/Functional/UserCrudTest.php create mode 100644 tests/Http/Model/HttpProcessExecutionTest.php create mode 100644 tests/Http/ValueResolver/HttpProcessExecuteValueResolverTest.php create mode 100644 tests/Manager/ProcessConfigurationsManagerTest.php create mode 100644 tests/Manager/ProcessExecutionManagerTest.php create mode 100644 tests/Message/CronProcessMessageHandlerTest.php create mode 100644 tests/Message/CronProcessMessageTest.php create mode 100644 tests/Message/ProcessExecuteHandlerTest.php create mode 100644 tests/Message/ProcessExecuteMessageTest.php create mode 100644 tests/Migrations/MigrationTestCase.php create mode 100644 tests/Migrations/Version20231006111525Test.php create mode 100644 tests/Migrations/Version20240729151928Test.php create mode 100644 tests/Migrations/Version20240730090403Test.php create mode 100644 tests/Migrations/Version20241007134542Test.php create mode 100644 tests/Migrations/Version20241007152613Test.php create mode 100644 tests/Migrations/Version20241009075733Test.php create mode 100644 tests/Monolog/Handler/DoctrineProcessHandlerTest.php create mode 100644 tests/Repository/ProcessExecutionRepositoryTest.php create mode 100644 tests/Repository/ProcessScheduleRepositoryTest.php create mode 100644 tests/Scheduler/CronSchedulerTest.php create mode 100644 tests/Twig/Components/BootstrapModalTest.php create mode 100644 tests/Twig/Extension/LogLevelExtensionTest.php create mode 100644 tests/Twig/Extension/MD5ExtensionTest.php create mode 100644 tests/Twig/Extension/ProcessExecutionExtensionTest.php create mode 100644 tests/Twig/Extension/ProcessExtensionTest.php create mode 100644 tests/Twig/Runtime/LogLevelExtensionRuntimeTest.php create mode 100644 tests/Twig/Runtime/MD5ExtensionRuntimeTest.php create mode 100644 tests/Twig/Runtime/ProcessExecutionExtensionRuntimeTest.php create mode 100644 tests/Twig/Runtime/ProcessExtensionRuntimeTest.php create mode 100644 tests/Validator/CronExpressionValidatorTest.php create mode 100644 tests/Validator/EveryExpressionValidatorTest.php create mode 100644 tests/Validator/IsValidProcessCodeValidatorTest.php diff --git a/.gitignore b/.gitignore index 40db549..4b0fd5a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ yarn-error.log ###> vincentlanglet/twig-cs-fixer ### /.twig-cs-fixer.cache ###< vincentlanglet/twig-cs-fixer ### + +# Generated by Symfony when the test application boots +/tests/App/config/reference.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 15db80c..3a46040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Latest ------ ## Changes +* [#81](https://github.com/cleverage/ui-process-bundle/issues/81) Add missing tests: functional tests of the UI and the HTTP API on a test application (SQLite, in-memory Messenger), unit tests of every class (DI, command, migrations, entities, managers, message handlers, scheduler, Monolog handlers, forms, EasyAdmin fields and filters, Twig, validators); `memory_limit` set to 512M for the test suite. * [#83](https://github.com/cleverage/ui-process-bundle/issues/83) Remove `UploadAndExecuteAction` (route `process_upload_and_execute`), `ProcessUploadFileType` and `ProcessConfigurationValueResolver`: the action was broken and unreachable, file uploads are handled by `LaunchAction` (`entrypoint_type: file`). * [#84](https://github.com/cleverage/ui-process-bundle/issues/84) Add `LogRecord::hasContextInfo()`, deprecate the misnamed `LogRecord::contextIsEmpty()` (it returns `true` when the context is not empty). Add tests. * [#85](https://github.com/cleverage/ui-process-bundle/issues/85) `ProcessHandler`: default report increment level aligned on the bundle configuration (`Warning`); declare the `symfony/ux-twig-component` dependency. Add tests. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c3e7947..56fc4af 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,6 +14,10 @@ tests + + + + src diff --git a/tests/Admin/Field/ContextFieldTest.php b/tests/Admin/Field/ContextFieldTest.php new file mode 100644 index 0000000..d696b85 --- /dev/null +++ b/tests/Admin/Field/ContextFieldTest.php @@ -0,0 +1,44 @@ +getAsDto(); + + self::assertSame('myProperty', $dto->getProperty()); + self::assertSame('My label', $dto->getLabel()); + self::assertSame('@CleverAgeUiProcess/admin/field/array.html.twig', $dto->getTemplatePath()); + } + + public function testNewWithTranslatableLabel(): void + { + $label = new TranslatableMessage('my.label'); + + self::assertSame($label, ContextField::new('myProperty', $label)->getAsDto()->getLabel()); + } + + public function testNewWithoutLabel(): void + { + self::assertNull(ContextField::new('myProperty')->getAsDto()->getLabel()); + } +} diff --git a/tests/Admin/Field/EnumFieldTest.php b/tests/Admin/Field/EnumFieldTest.php new file mode 100644 index 0000000..828f15b --- /dev/null +++ b/tests/Admin/Field/EnumFieldTest.php @@ -0,0 +1,44 @@ +getAsDto(); + + self::assertSame('myProperty', $dto->getProperty()); + self::assertSame('My label', $dto->getLabel()); + self::assertSame('@CleverAgeUiProcess/admin/field/enum.html.twig', $dto->getTemplatePath()); + } + + public function testNewWithTranslatableLabel(): void + { + $label = new TranslatableMessage('my.label'); + + self::assertSame($label, EnumField::new('myProperty', $label)->getAsDto()->getLabel()); + } + + public function testNewWithoutLabel(): void + { + self::assertNull(EnumField::new('myProperty')->getAsDto()->getLabel()); + } +} diff --git a/tests/Admin/Field/LogLevelFieldTest.php b/tests/Admin/Field/LogLevelFieldTest.php new file mode 100644 index 0000000..6bfe06b --- /dev/null +++ b/tests/Admin/Field/LogLevelFieldTest.php @@ -0,0 +1,44 @@ +getAsDto(); + + self::assertSame('myProperty', $dto->getProperty()); + self::assertSame('My label', $dto->getLabel()); + self::assertSame('@CleverAgeUiProcess/admin/field/log_level.html.twig', $dto->getTemplatePath()); + } + + public function testNewWithTranslatableLabel(): void + { + $label = new TranslatableMessage('my.label'); + + self::assertSame($label, LogLevelField::new('myProperty', $label)->getAsDto()->getLabel()); + } + + public function testNewWithoutLabel(): void + { + self::assertNull(LogLevelField::new('myProperty')->getAsDto()->getLabel()); + } +} diff --git a/tests/Admin/Filter/LogProcessFilterTest.php b/tests/Admin/Filter/LogProcessFilterTest.php new file mode 100644 index 0000000..09a4f0e --- /dev/null +++ b/tests/Admin/Filter/LogProcessFilterTest.php @@ -0,0 +1,106 @@ + 'demo.a', 'demo.b' => 'demo.b'])->getAsDto(); + + self::assertSame(LogProcessFilter::class, $dto->getFqcn()); + self::assertSame('process', $dto->getProperty()); + self::assertSame('Process', $dto->getLabel()); + self::assertSame(ChoiceFilterType::class, $dto->getFormType()); + self::assertSame(['choices' => ['demo.a' => 'demo.a', 'demo.b' => 'demo.b']], $dto->getFormTypeOption('value_type_options')); + self::assertSame(['comparison' => ComparisonType::EQ, 'value' => null], $dto->getFormTypeOption('data')); + } + + public function testNewWithExecutionIdReplacesChoices(): void + { + $dto = LogProcessFilter::new('Process', ['demo.a' => 'demo.a'], '12')->getAsDto(); + + self::assertSame(['choices' => [12 => '12']], $dto->getFormTypeOption('value_type_options')); + self::assertSame(['comparison' => ComparisonType::EQ, 'value' => '12'], $dto->getFormTypeOption('data')); + } + + public function testNewWithNonNumericExecutionIdKeepsChoices(): void + { + $dto = LogProcessFilter::new('Process', ['demo.a' => 'demo.a'], 'demo.a')->getAsDto(); + + self::assertSame(['choices' => ['demo.a' => 'demo.a']], $dto->getFormTypeOption('value_type_options')); + self::assertSame(['comparison' => ComparisonType::EQ, 'value' => 'demo.a'], $dto->getFormTypeOption('data')); + } + + public function testApplyWithExecutionId(): void + { + $queryBuilder = $this->createQueryBuilder(); + + $this->apply($queryBuilder, '12'); + + self::assertSame( + 'SELECT entity FROM '.LogRecord::class.' entity INNER JOIN entity.processExecution pe WHERE pe.id = :id', + $queryBuilder->getDQL() + ); + self::assertCount(1, $queryBuilder->getParameters()); + self::assertSame('12', $queryBuilder->getParameter('id')?->getValue()); + } + + public function testApplyWithProcessCodes(): void + { + $queryBuilder = $this->createQueryBuilder(); + + $this->apply($queryBuilder, ['demo.a', 'demo.b']); + + self::assertSame( + 'SELECT entity FROM '.LogRecord::class.' entity INNER JOIN entity.processExecution pe WHERE pe.code IN (:codes)', + $queryBuilder->getDQL() + ); + self::assertCount(1, $queryBuilder->getParameters()); + self::assertSame(['demo.a', 'demo.b'], $queryBuilder->getParameter('codes')?->getValue()); + } + + private function createQueryBuilder(): QueryBuilder + { + $entityManager = $this->createStub(EntityManagerInterface::class); + $entityManager->method('getExpressionBuilder')->willReturn(new Expr()); + + return (new QueryBuilder($entityManager))->select('entity')->from(LogRecord::class, 'entity'); + } + + private function apply(QueryBuilder $queryBuilder, mixed $value): void + { + $filter = LogProcessFilter::new('Process', []); + $filterDataDto = FilterDataDto::new(0, $filter->getAsDto(), 'entity', ['comparison' => ComparisonType::EQ, 'value' => $value]); + + /** @var EntityDto $entityDto */ + $entityDto = new EntityDto(LogRecord::class, new ClassMetadata(LogRecord::class)); + + $filter->apply($queryBuilder, $filterDataDto, null, $entityDto); + } +} diff --git a/tests/Admin/Filter/ProcessExecutionDurationFilterTest.php b/tests/Admin/Filter/ProcessExecutionDurationFilterTest.php new file mode 100644 index 0000000..9c94f51 --- /dev/null +++ b/tests/Admin/Filter/ProcessExecutionDurationFilterTest.php @@ -0,0 +1,104 @@ +getAsDto(); + + self::assertSame(ProcessExecutionDurationFilter::class, $dto->getFqcn()); + self::assertSame('duration', $dto->getProperty()); + self::assertSame('Duration', $dto->getLabel()); + self::assertSame(NumericFilterType::class, $dto->getFormType()); + self::assertSame('EasyAdminBundle', $dto->getFormTypeOption('translation_domain')); + } + + #[DataProvider('provideComparisons')] + public function testApplyWithComparison(string $comparison): void + { + $queryBuilder = $this->apply(['comparison' => $comparison, 'value' => 60]); + + self::assertSame( + 'SELECT entity FROM '.ProcessExecution::class.' entity WHERE entity.endDate '.$comparison + .' date_add(entity.startDate, 60, \'SECOND\')', + $queryBuilder->getDQL() + ); + } + + /** + * @return iterable + */ + public static function provideComparisons(): iterable + { + yield 'eq' => [ComparisonType::EQ]; + yield 'neq' => [ComparisonType::NEQ]; + yield 'gt' => [ComparisonType::GT]; + yield 'gte' => [ComparisonType::GTE]; + yield 'lt' => [ComparisonType::LT]; + yield 'lte' => [ComparisonType::LTE]; + } + + public function testApplyBetween(): void + { + $queryBuilder = $this->apply(['comparison' => ComparisonType::BETWEEN, 'value' => 10, 'value2' => 120]); + + self::assertSame( + 'SELECT entity FROM '.ProcessExecution::class.' entity WHERE entity.endDate BETWEEN ' + .'date_add(entity.startDate, 10, \'SECOND\') and date_add(entity.startDate, 120, \'SECOND\')', + $queryBuilder->getDQL() + ); + } + + public function testApplyWithUnsupportedComparisonDoesNothing(): void + { + $queryBuilder = $this->apply(['comparison' => ComparisonType::CONTAINS, 'value' => 10]); + + self::assertSame('SELECT entity FROM '.ProcessExecution::class.' entity', $queryBuilder->getDQL()); + } + + /** + * @param array{comparison: string, value: mixed, value2?: mixed} $formData + */ + private function apply(array $formData): QueryBuilder + { + $queryBuilder = (new QueryBuilder($this->createStub(EntityManagerInterface::class))) + ->select('entity') + ->from(ProcessExecution::class, 'entity'); + $filter = ProcessExecutionDurationFilter::new('duration'); + $filterDataDto = FilterDataDto::new(0, $filter->getAsDto(), 'entity', $formData); + + /** @var EntityDto $entityDto */ + $entityDto = new EntityDto(ProcessExecution::class, new ClassMetadata(ProcessExecution::class)); + + $filter->apply($queryBuilder, $filterDataDto, null, $entityDto); + + return $queryBuilder; + } +} diff --git a/tests/App/TestKernel.php b/tests/App/TestKernel.php new file mode 100644 index 0000000..9658be3 --- /dev/null +++ b/tests/App/TestKernel.php @@ -0,0 +1,84 @@ +environment; + } + + public function getLogDir(): string + { + return self::getRunDir().'/logs'; + } + + protected function configureContainer(\Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator $container): void + { + $container->import(__DIR__.'/config/packages.yaml'); + } + + protected function configureRoutes(\Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator $routes): void + { + $routes->import(__DIR__.'/config/routes.yaml'); + } + + public static function getRunDir(): string + { + return self::$runDir ??= sys_get_temp_dir().'/ui_process_bundle_tests_'.getmypid(); + } +} diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml new file mode 100644 index 0000000..f76c0f7 --- /dev/null +++ b/tests/App/config/packages.yaml @@ -0,0 +1,98 @@ +parameters: + upload_directory: '%kernel.cache_dir%/uploads' + +framework: + secret: 'test' + test: true + http_method_override: false + session: + storage_factory_id: session.storage.factory.mock_file + router: + utf8: true + messenger: + transports: + execute_process: 'in-memory://' + scheduler_cron: 'in-memory://' + validation: + email_validation_mode: html5 + +security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: plaintext + firewalls: + main: + lazy: true + +doctrine: + dbal: + url: 'sqlite:///%kernel.cache_dir%/test.db' + orm: + auto_mapping: true + # As the Symfony recipe: the indexes of the bundle entities use snake_case column names + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + mappings: + CleverAgeUiProcessBundle: + type: attribute + is_bundle: true + +monolog: + handlers: + main: + type: 'null' + +twig: + default_path: '%kernel.project_dir%/templates' + +clever_age_process: + configurations: + test.process: + description: 'Process used by the functional tests' + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form: + description: 'Process launched with the UI form, text input' + entry_point: data + options: + ui: + ui_launch_mode: form + entrypoint_type: text + default: + input: 'default input' + context: + - key: 'key1' + value: 'value1' + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.upload: + description: 'Process launched with the UI form, file input' + entry_point: data + options: + ui: + ui_launch_mode: form + entrypoint_type: file + # Required with Symfony 8: without it, ProcessConfigurationsManager returns the `default` nested + # options closure (nested options defined with setDefault() are no longer supported) + default: + input: 'not used by a file input' + context: [] + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.private: + public: false + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.failing: + description: 'Process failing on initialization (missing required option)' + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\ConstantOutputTask' + +twig_component: + anonymous_template_directory: 'components/' + defaults: + App\Twig\Components\: 'components/' diff --git a/tests/App/config/routes.yaml b/tests/App/config/routes.yaml new file mode 100644 index 0000000..11c86f4 --- /dev/null +++ b/tests/App/config/routes.yaml @@ -0,0 +1,3 @@ +cleverage_ui_process: + resource: '@CleverAgeUiProcessBundle/config/routes' + type: directory diff --git a/tests/CleverAgeUiProcessBundleTest.php b/tests/CleverAgeUiProcessBundleTest.php new file mode 100644 index 0000000..7d19bd1 --- /dev/null +++ b/tests/CleverAgeUiProcessBundleTest.php @@ -0,0 +1,42 @@ +getPath()); + self::assertDirectoryExists($bundle->getPath().'/config/services'); + self::assertDirectoryExists($bundle->getPath().'/templates'); + } + + public function testContainerExtension(): void + { + $extension = (new CleverAgeUiProcessBundle())->getContainerExtension(); + + self::assertInstanceOf(CleverAgeUiProcessExtension::class, $extension); + self::assertSame('clever_age_ui_process', $extension->getAlias()); + } +} diff --git a/tests/Command/UserCreateCommandTest.php b/tests/Command/UserCreateCommandTest.php new file mode 100644 index 0000000..1ffe9b1 --- /dev/null +++ b/tests/Command/UserCreateCommandTest.php @@ -0,0 +1,162 @@ +passwordHasher = new UserPasswordHasher( + new PasswordHasherFactory([User::class => ['algorithm' => 'bcrypt', 'cost' => 4]]) + ); + $this->persisted = []; + } + + public function testDefinition(): void + { + $command = $this->createCommand($this->createStub(EntityManagerInterface::class)); + + self::assertSame('cleverage:ui-process:user-create', $command->getName()); + self::assertSame('Command to create a new admin into database for ui process.', $command->getDescription()); + self::assertFalse($command->getDefinition()->getArgument('email')->isRequired()); + self::assertFalse($command->getDefinition()->getArgument('password')->isRequired()); + } + + public function testCreateUserFromArguments(): void + { + $tester = new CommandTester($this->createCommand($this->createEntityManager())); + + $exitCode = $tester->execute(['email' => 'admin@example.com', 'password' => 'my-password']); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('User created.', $tester->getDisplay()); + self::assertStringNotContainsString('Please enter', $tester->getDisplay()); + $this->assertUserCreated('admin@example.com', 'my-password'); + } + + public function testCreateUserInteractively(): void + { + $tester = new CommandTester($this->createCommand($this->createEntityManager())); + $tester->setInputs(['admin@example.com', 'my-password']); + + $exitCode = $tester->execute([]); + + self::assertSame(Command::SUCCESS, $exitCode); + $display = $tester->getDisplay(); + self::assertStringContainsString('Please enter the email.', $display); + self::assertStringContainsString('Please enter the user password.', $display); + self::assertStringNotContainsString('my-password', $display); + self::assertStringContainsString('User created.', $display); + $this->assertUserCreated('admin@example.com', 'my-password'); + } + + public function testInvalidAnswersAreAskedAgain(): void + { + $tester = new CommandTester($this->createCommand($this->createEntityManager())); + $tester->setInputs(['not-an-email', 'admin@example.com', '', 'short', 'long-enough-password']); + + $exitCode = $tester->execute([], ['decorated' => false]); + + self::assertSame(Command::SUCCESS, $exitCode); + $display = $tester->getDisplay(); + self::assertSame(2, substr_count($display, 'Please enter the email.')); + self::assertSame(3, substr_count($display, 'Please enter the user password.')); + self::assertStringContainsString('This value is not a valid email address.', $display); + self::assertStringContainsString('This value should not be blank.', $display); + self::assertStringContainsString('This value is too short. It should have 8 characters or more.', $display); + $this->assertUserCreated('admin@example.com', 'long-enough-password'); + } + + public function testOnlyMissingArgumentsAreAsked(): void + { + $tester = new CommandTester($this->createCommand($this->createEntityManager())); + $tester->setInputs(['my-password']); + + $exitCode = $tester->execute(['email' => 'admin@example.com']); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringNotContainsString('Please enter the email.', $tester->getDisplay()); + self::assertStringContainsString('Please enter the user password.', $tester->getDisplay()); + $this->assertUserCreated('admin@example.com', 'my-password'); + } + + public function testArgumentsAreNotValidated(): void + { + // Validation constraints only apply to the answers of the interactive questions + $tester = new CommandTester($this->createCommand($this->createEntityManager())); + + $exitCode = $tester->execute(['email' => 'not-an-email', 'password' => 'short']); + + self::assertSame(Command::SUCCESS, $exitCode); + $this->assertUserCreated('not-an-email', 'short'); + } + + private function createCommand(EntityManagerInterface $entityManager): Command + { + $application = new Application(); + $application->addCommands([new UserCreateCommand( + Validation::createValidator(), + $this->passwordHasher, + $entityManager, + )]); + + return $application->find('cleverage:ui-process:user-create'); + } + + private function createEntityManager(): EntityManagerInterface + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('persist') + ->with(self::isInstanceOf(User::class)) + ->willReturnCallback(function (object $user): void { + \assert($user instanceof User); + $this->persisted[] = $user; + }); + $entityManager->expects(self::once())->method('flush'); + + return $entityManager; + } + + private function assertUserCreated(string $email, string $plainPassword): void + { + self::assertCount(1, $this->persisted); + $user = $this->persisted[0]; + self::assertSame($email, $user->getEmail()); + // The command stores ROLE_USER and ROLE_ADMIN, User::getRoles() always adds ROLE_USER + self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], array_values(array_unique($user->getRoles()))); + self::assertNotSame($plainPassword, $user->getPassword()); + self::assertTrue($this->passwordHasher->isPasswordValid($user, $plainPassword)); + } +} diff --git a/tests/Controller/Admin/Security/LogoutControllerTest.php b/tests/Controller/Admin/Security/LogoutControllerTest.php new file mode 100644 index 0000000..6771c0f --- /dev/null +++ b/tests/Controller/Admin/Security/LogoutControllerTest.php @@ -0,0 +1,48 @@ +createMock(Security::class); + $security->expects(self::once())->method('logout'); + $router = $this->createStub(RouterInterface::class); + $router->method('generate')->willReturnCallback(static fn (string $route, array $parameters, int $type): string => UrlGeneratorInterface::ABSOLUTE_PATH === $type && 'process_login' === $route ? '/process/login' : ''); + $container = new Container(); + $container->set('router', $router); + + $controller = new LogoutController($security); + $controller->setContainer($container); + $response = $controller(); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/process/login', $response->getTargetUrl()); + } +} diff --git a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php new file mode 100644 index 0000000..eaf7d7a --- /dev/null +++ b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php @@ -0,0 +1,235 @@ +createContainer('prod'); + (new CleverAgeUiProcessExtension())->load([], $container); + + // Services of every config/services/*.yaml file are loaded + self::assertTrue($container->hasDefinition('cleverage_ui_process.manager.process_execution')); + self::assertTrue($container->hasParameter('upload_directory')); + + self::assertSame( + ['ROLE_ADMIN' => 'ROLE_ADMIN'], + $container->getDefinition(UserCrudController::class)->getArgument('$roles') + ); + + $fileHandler = $container->getDefinition('cleverage_ui_process.monolog_handler.process'); + self::assertSame('Info', $fileHandler->getArgument('$level')); + self::assertSame([['setReportIncrementLevel', ['Warning']]], $fileHandler->getMethodCalls()); + + $databaseHandler = $container->getDefinition('cleverage_ui_process.monolog_handler.doctrine_process'); + self::assertSame('Info', $databaseHandler->getArgument('$level')); + self::assertFalse($databaseHandler->hasMethodCall('disable')); + + self::assertSame( + 'bundles/cleverageuiprocess/logo.jpg', + $container->getDefinition(ProcessDashboardController::class)->getArgument('$logoPath') + ); + } + + public function testLoadWithDefaultConfigurationInDevEnvironment(): void + { + $container = $this->createContainer('dev'); + (new CleverAgeUiProcessExtension())->load([], $container); + + self::assertSame( + 'Debug', + $container->getDefinition('cleverage_ui_process.monolog_handler.process')->getArgument('$level') + ); + self::assertSame( + 'Debug', + $container->getDefinition('cleverage_ui_process.monolog_handler.doctrine_process')->getArgument('$level') + ); + } + + public function testLoadWithCustomConfiguration(): void + { + $container = $this->createContainer('prod'); + (new CleverAgeUiProcessExtension())->load([ + [ + 'security' => ['roles' => ['ROLE_ADMIN', 'ROLE_OPERATOR']], + 'logs' => [ + 'store_in_database' => false, + 'database_level' => 'Error', + 'file_level' => 'Notice', + 'report_increment_level' => 'Critical', + ], + 'design' => ['logo_path' => 'images/my-logo.png'], + ], + ], $container); + + self::assertSame( + ['ROLE_ADMIN' => 'ROLE_ADMIN', 'ROLE_OPERATOR' => 'ROLE_OPERATOR'], + $container->getDefinition(UserCrudController::class)->getArgument('$roles') + ); + + $fileHandler = $container->getDefinition('cleverage_ui_process.monolog_handler.process'); + self::assertSame('Notice', $fileHandler->getArgument('$level')); + self::assertSame([['setReportIncrementLevel', ['Critical']]], $fileHandler->getMethodCalls()); + + $databaseHandler = $container->getDefinition('cleverage_ui_process.monolog_handler.doctrine_process'); + self::assertSame('Error', $databaseHandler->getArgument('$level')); + self::assertTrue($databaseHandler->hasMethodCall('disable')); + $calls = $databaseHandler->getMethodCalls(); + self::assertSame(['disable', []], end($calls)); + + self::assertSame( + 'images/my-logo.png', + $container->getDefinition(ProcessDashboardController::class)->getArgument('$logoPath') + ); + } + + public function testPrepend(): void + { + $container = $this->createContainer('prod'); + foreach (['monolog', 'doctrine_migrations', 'security'] as $alias) { + $container->registerExtension($this->createExtension($alias)); + } + + (new CleverAgeUiProcessExtension())->prepend($container); + + $monolog = $container->getExtensionConfig('monolog'); + self::assertCount(1, $monolog); + self::assertSame( + [ + 'pb_ui_file' => ['type' => 'service', 'id' => 'cleverage_ui_process.monolog_handler.process'], + 'pb_ui_orm' => ['type' => 'service', 'id' => 'cleverage_ui_process.monolog_handler.doctrine_process'], + 'pb_ui_file_filter' => [ + 'type' => 'filter', + 'handler' => 'pb_ui_file', + 'channels' => ['cleverage_process', 'cleverage_process_task'], + ], + 'pb_ui_orm_filter' => [ + 'type' => 'filter', + 'handler' => 'pb_ui_orm', + 'channels' => ['cleverage_process', 'cleverage_process_task'], + ], + ], + $monolog[0]['handlers'] + ); + + $migrations = $container->getExtensionConfig('doctrine_migrations'); + self::assertCount(1, $migrations); + $paths = $migrations[0]['migrations_paths']; + self::assertIsArray($paths); + self::assertSame(['CleverAge\UiProcessBundle\Migrations'], array_keys($paths)); + self::assertIsString($paths['CleverAge\UiProcessBundle\Migrations']); + self::assertSame( + realpath(__DIR__.'/../../src/Migrations'), + realpath($paths['CleverAge\UiProcessBundle\Migrations']) + ); + + self::assertSame( + [ + [ + 'messenger' => [ + 'transport' => [ + [ + 'name' => 'execute_process', + 'dsn' => 'doctrine://default', + 'retry_strategy' => ['max_retries' => 0], + ], + ], + 'routing' => [ProcessExecuteMessage::class => 'execute_process'], + ], + ], + ], + $container->getExtensionConfig('framework') + ); + + $security = $container->getExtensionConfig('security'); + self::assertCount(1, $security); + self::assertSame( + ['process_user_provider' => ['entity' => ['class' => User::class, 'property' => 'email']]], + $security[0]['providers'] + ); + self::assertSame( + [ + 'main' => [ + 'provider' => 'process_user_provider', + 'custom_authenticator' => ['cleverage_ui_process.security.http_process_execution_authenticator'], + 'form_login' => ['login_path' => 'process_login', 'check_path' => 'process_login'], + 'logout' => ['path' => 'process_logout', 'target' => 'process_login', 'clear_site_data' => '*'], + ], + ], + $security[0]['firewalls'] + ); + } + + public function testPrependedFrameworkConfigurationComesFirst(): void + { + $container = $this->createContainer('prod'); + foreach (['monolog', 'doctrine_migrations', 'security', 'framework'] as $alias) { + $container->registerExtension($this->createExtension($alias)); + } + $container->loadFromExtension('framework', ['secret' => 'app']); + + (new CleverAgeUiProcessExtension())->prepend($container); + + // The application configuration is processed last, so it can override the bundle one + $framework = $container->getExtensionConfig('framework'); + self::assertCount(2, $framework); + self::assertArrayHasKey('messenger', $framework[0]); + self::assertSame(['secret' => 'app'], $framework[1]); + } + + private function createContainer(string $env): ContainerBuilder + { + return new ContainerBuilder(new ParameterBag([ + 'kernel.environment' => $env, + 'kernel.debug' => false, + 'kernel.project_dir' => sys_get_temp_dir(), + 'kernel.logs_dir' => sys_get_temp_dir().'/logs', + 'kernel.cache_dir' => sys_get_temp_dir().'/cache', + ])); + } + + private function createExtension(string $alias): Extension + { + return new class($alias) extends Extension { + public function __construct(private readonly string $alias) + { + } + + public function load(array $configs, ContainerBuilder $container): void + { + } + + public function getAlias(): string + { + return $this->alias; + } + }; + } +} diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..c092ab9 --- /dev/null +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,161 @@ + + */ + public static function provideEnvironments(): iterable + { + yield 'dev' => ['dev', 'Debug']; + yield 'prod' => ['prod', 'Info']; + yield 'test' => ['test', 'Info']; + } + + #[DataProvider('provideEnvironments')] + public function testDefaultConfiguration(string $env, string $expectedLevel): void + { + self::assertSame( + [ + 'security' => [ + 'roles' => ['ROLE_ADMIN'], + ], + 'logs' => [ + 'store_in_database' => true, + 'database_level' => $expectedLevel, + 'file_level' => $expectedLevel, + 'report_increment_level' => 'Warning', + ], + 'design' => [ + 'logo_path' => 'bundles/cleverageuiprocess/logo.jpg', + ], + ], + $this->process($env, []) + ); + } + + public function testRootName(): void + { + self::assertSame( + 'clever_age_ui_process', + (new Configuration('prod'))->getConfigTreeBuilder()->buildTree()->getName() + ); + } + + public function testCustomConfiguration(): void + { + self::assertSame( + [ + 'security' => [ + 'roles' => ['ROLE_ADMIN', 'ROLE_OPERATOR'], + ], + 'logs' => [ + 'store_in_database' => false, + 'database_level' => 'Error', + 'file_level' => 'Notice', + 'report_increment_level' => 'Critical', + ], + 'design' => [ + 'logo_path' => 'images/my-logo.png', + ], + ], + $this->process('dev', [ + 'security' => ['roles' => ['ROLE_ADMIN', 'ROLE_OPERATOR']], + 'logs' => [ + 'store_in_database' => false, + 'database_level' => 'Error', + 'file_level' => 'Notice', + 'report_increment_level' => 'Critical', + ], + 'design' => ['logo_path' => 'images/my-logo.png'], + ]) + ); + } + + public function testPartialConfigurationKeepsOtherDefaults(): void + { + $config = $this->process('dev', ['logs' => ['file_level' => 'Error']]); + + self::assertSame( + [ + 'file_level' => 'Error', + 'store_in_database' => true, + 'database_level' => 'Debug', + 'report_increment_level' => 'Warning', + ], + $config['logs'] + ); + self::assertSame(['roles' => ['ROLE_ADMIN']], $config['security']); + } + + public function testLastConfigurationWins(): void + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration('prod'), [ + ['design' => ['logo_path' => 'first.png'], 'logs' => ['store_in_database' => false]], + ['design' => ['logo_path' => 'second.png']], + ]); + + self::assertSame('second.png', $config['design']['logo_path']); + self::assertFalse($config['logs']['store_in_database']); + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidConfigurations(): iterable + { + yield 'unknown root key' => [['unknown' => true], 'Unrecognized option "unknown"']; + yield 'unknown logs key' => [['logs' => ['level' => 'Debug']], 'Unrecognized option "level"']; + yield 'store_in_database not a boolean' => [ + ['logs' => ['store_in_database' => 'yes']], + 'clever_age_ui_process.logs.store_in_database', + ]; + yield 'roles not an array' => [['security' => ['roles' => 'ROLE_ADMIN']], 'clever_age_ui_process.security.roles']; + yield 'role not a scalar' => [['security' => ['roles' => [['ROLE_ADMIN']]]], 'clever_age_ui_process.security.roles']; + yield 'logo_path not a scalar' => [['design' => ['logo_path' => ['a.png']]], 'clever_age_ui_process.design.logo_path']; + } + + /** + * @param array $config + */ + #[DataProvider('provideInvalidConfigurations')] + public function testInvalidConfiguration(array $config, string $expectedMessage): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage($expectedMessage); + + $this->process('prod', $config); + } + + /** + * @param array $config + * + * @return array + */ + private function process(string $env, array $config): array + { + return (new Processor())->processConfiguration(new Configuration($env), [$config]); + } +} diff --git a/tests/Entity/Enum/ProcessExecutionStatusTest.php b/tests/Entity/Enum/ProcessExecutionStatusTest.php new file mode 100644 index 0000000..4adeb1e --- /dev/null +++ b/tests/Entity/Enum/ProcessExecutionStatusTest.php @@ -0,0 +1,39 @@ +createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with('enum.process_execution_status.failed', [], 'enums', 'fr') + ->willReturn('Echec'); + + self::assertSame('Echec', ProcessExecutionStatus::Failed->trans($translator, 'fr')); + } +} diff --git a/tests/Entity/Enum/ProcessScheduleTypeTest.php b/tests/Entity/Enum/ProcessScheduleTypeTest.php new file mode 100644 index 0000000..a3454ff --- /dev/null +++ b/tests/Entity/Enum/ProcessScheduleTypeTest.php @@ -0,0 +1,39 @@ +createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with('enum.process_schedule_type.every', [], 'enums', null) + ->willReturn('Every'); + + self::assertSame('Every', ProcessScheduleType::EVERY->trans($translator)); + } +} diff --git a/tests/Entity/LogRecordTest.php b/tests/Entity/LogRecordTest.php index cea39e5..fb06144 100644 --- a/tests/Entity/LogRecordTest.php +++ b/tests/Entity/LogRecordTest.php @@ -46,6 +46,19 @@ public function testDeprecatedContextIsEmptyIsUnchanged(): void self::assertFalse($this->createLogRecord([])->contextIsEmpty()); } + public function testAccessors(): void + { + $execution = new ProcessExecution('demo.process', 'demo.process.log'); + $record = new LogRecord(new \Monolog\LogRecord(new \DateTimeImmutable('2026-01-01 10:00:00'), 'cleverage_process', Level::Warning, 'message', []), $execution); + + self::assertNull($record->getId()); + self::assertSame($execution, $record->getProcessExecution()); + self::assertSame('cleverage_process', $record->channel); + self::assertSame(Level::Warning->value, $record->level); + self::assertSame('message', $record->message); + self::assertSame('2026-01-01 10:00:00', $record->createdAt->format('Y-m-d H:i:s')); + } + /** * @param array $context */ diff --git a/tests/Entity/ProcessExecutionTest.php b/tests/Entity/ProcessExecutionTest.php new file mode 100644 index 0000000..64f1c0f --- /dev/null +++ b/tests/Entity/ProcessExecutionTest.php @@ -0,0 +1,112 @@ + 'value']); + $after = new \DateTimeImmutable(); + + self::assertNull($execution->getId()); + self::assertSame('demo.process', $execution->code); + self::assertSame('demo.process', $execution->getCode()); + self::assertSame('demo.process.log', $execution->logFilename); + self::assertSame(['key' => 'value'], $execution->getContext()); + self::assertSame(ProcessExecutionStatus::Started, $execution->status); + self::assertNull($execution->endDate); + self::assertSame([], $execution->getReport()); + self::assertGreaterThanOrEqual($before, $execution->startDate); + self::assertLessThanOrEqual($after, $execution->startDate); + } + + public function testCodeIsTruncatedTo255Characters(): void + { + $execution = new ProcessExecution(str_repeat('a', 300), 'demo.process.log'); + + self::assertSame(str_repeat('a', 255), $execution->getCode()); + } + + public function testContextDefaultsToEmptyArray(): void + { + self::assertSame([], (new ProcessExecution('demo.process', 'demo.process.log'))->getContext()); + self::assertSame([], (new ProcessExecution('demo.process', 'demo.process.log', null))->getContext()); + } + + public function testSetContext(): void + { + $execution = new ProcessExecution('demo.process', 'demo.process.log'); + $execution->setContext(['foo' => 'bar']); + + self::assertSame(['foo' => 'bar'], $execution->getContext()); + } + + public function testToString(): void + { + $execution = new ProcessExecution('demo.process', 'demo.process.log'); + self::assertSame(' (demo.process)', (string) $execution); + + (new \ReflectionProperty(ProcessExecution::class, 'id'))->setValue($execution, 42); + self::assertSame(42, $execution->getId()); + self::assertSame('42 (demo.process)', (string) $execution); + } + + public function testSetStatus(): void + { + $execution = new ProcessExecution('demo.process', 'demo.process.log'); + $execution->setStatus(ProcessExecutionStatus::Failed); + + self::assertSame(ProcessExecutionStatus::Failed, $execution->status); + } + + public function testEnd(): void + { + $execution = new ProcessExecution('demo.process', 'demo.process.log'); + $execution->end(); + + self::assertInstanceOf(\DateTimeImmutable::class, $execution->endDate); + self::assertGreaterThanOrEqual($execution->startDate, $execution->endDate); + } + + public function testReport(): void + { + $execution = new ProcessExecution('demo.process', 'demo.process.log'); + $execution->addReport('Warning', 3); + $execution->addReport('Error', 1); + $execution->addReport('Warning', 4); + + self::assertSame(['Warning' => 4, 'Error' => 1], $execution->getReport()); + self::assertSame(4, $execution->getReport('Warning')); + self::assertNull($execution->getReport('Info')); + self::assertSame(0, $execution->getReport('Info', 0)); + } + + public function testDuration(): void + { + $execution = new ProcessExecution('demo.process', 'demo.process.log'); + self::assertNull($execution->duration()); + + $execution->endDate = $execution->startDate->modify('+1 hour +2 minutes +3 seconds'); + self::assertSame('01 hour(s) 02 min(s) 03 s', $execution->duration()); + self::assertSame('1:2:3', $execution->duration('%h:%i:%s')); + } +} diff --git a/tests/Entity/ProcessScheduleTest.php b/tests/Entity/ProcessScheduleTest.php new file mode 100644 index 0000000..6339179 --- /dev/null +++ b/tests/Entity/ProcessScheduleTest.php @@ -0,0 +1,133 @@ +getId()); + self::assertNull($schedule->getInput()); + self::assertSame([], $schedule->getContext()); + self::assertNull($schedule->getNextExecution()); // @phpstan-ignore staticMethod.alreadyNarrowedType + + self::assertSame($schedule, $schedule->setProcess('demo.process')); + self::assertSame($schedule, $schedule->setType(ProcessScheduleType::CRON)); + self::assertSame($schedule, $schedule->setExpression('* * * * *')); + self::assertSame($schedule, $schedule->setInput('input')); + $schedule->setContext(['key' => 'value']); + + self::assertSame('demo.process', $schedule->getProcess()); + self::assertSame(ProcessScheduleType::CRON, $schedule->getType()); + self::assertSame('* * * * *', $schedule->getExpression()); + self::assertSame('input', $schedule->getInput()); + self::assertSame(['key' => 'value'], $schedule->getContext()); + + $schedule->setInput(null); + self::assertNull($schedule->getInput()); + } + + public function testGetContextDecodesJsonString(): void + { + // The JSON column may hold a raw JSON string (legacy rows) + $schedule = new ProcessSchedule(); + (new \ReflectionProperty(ProcessSchedule::class, 'context'))->setValue($schedule, '["a","b"]'); + + self::assertSame(['a', 'b'], $schedule->getContext()); + } + + /** + * @param list $expectedViolations + */ + #[DataProvider('provideValidation')] + public function testValidation(string $process, ProcessScheduleType $type, string $expression, array $expectedViolations): void + { + $schedule = (new ProcessSchedule()) + ->setProcess($process) + ->setType($type) + ->setExpression($expression); + + $registry = $this->createStub(ProcessConfigurationRegistry::class); + $registry->method('hasProcessConfiguration') + ->willReturnCallback(static fn (string $code): bool => 'demo.process' === $code); + $registry->method('getProcessConfiguration')->willReturn(new ProcessConfiguration('demo.process', [])); + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory(new ConstraintValidatorFactory([ + IsValidProcessCodeValidator::class => new IsValidProcessCodeValidator($registry), + ])) + ->getValidator(); + + $violations = array_map( + static fn (ConstraintViolationInterface $violation): string => $violation->getPropertyPath().': '.$violation->getMessage(), + iterator_to_array($validator->validate($schedule)) + ); + + self::assertSame($expectedViolations, $violations); + } + + /** + * @return iterable}> + */ + public static function provideValidation(): iterable + { + yield 'valid cron' => ['demo.process', ProcessScheduleType::CRON, '*/5 * * * *', []]; + yield 'valid every' => ['demo.process', ProcessScheduleType::EVERY, '10 seconds', []]; + yield 'invalid cron' => [ + 'demo.process', + ProcessScheduleType::CRON, + '10 seconds', + ['expression: The value "10 seconds" is not a valid cron expression.'], + ]; + yield 'invalid every' => [ + 'demo.process', + ProcessScheduleType::EVERY, + '*/5 * * * *', + ['expression: The value "*/5 * * * *" is not every valid expression.'], + ]; + yield 'unknown process' => [ + 'demo.unknown', + ProcessScheduleType::CRON, + '* * * * *', + ['process: The process "demo.unknown" does not exist.'], + ]; + } +} diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php new file mode 100644 index 0000000..6ddbf4a --- /dev/null +++ b/tests/Entity/UserTest.php @@ -0,0 +1,81 @@ +getId()); + self::assertNull($user->getFirstname()); + self::assertNull($user->getLastname()); + self::assertNull($user->getPassword()); + self::assertNull($user->getTimezone()); + self::assertNull($user->getLocale()); + self::assertNull($user->getToken()); + self::assertSame(['ROLE_USER'], $user->getRoles()); + } + + public function testGettersAndSetters(): void + { + $user = new User(); + + self::assertSame($user, $user->setEmail('admin@example.com')); + self::assertSame($user, $user->setFirstname('John')); + self::assertSame($user, $user->setLastname('Doe')); + self::assertSame($user, $user->setPassword('hashed')); + self::assertSame($user, $user->setTimezone('Europe/Paris')); + self::assertSame($user, $user->setLocale('fr')); + self::assertSame($user, $user->setToken('token')); + self::assertSame($user, $user->setRoles(['ROLE_ADMIN'])); + + self::assertSame('admin@example.com', $user->getEmail()); + self::assertSame('admin@example.com', $user->getUserIdentifier()); + self::assertSame('admin@example.com', $user->getUsername()); + self::assertSame('John', $user->getFirstname()); + self::assertSame('Doe', $user->getLastname()); + self::assertSame('hashed', $user->getPassword()); + self::assertSame('Europe/Paris', $user->getTimezone()); + self::assertSame('fr', $user->getLocale()); + self::assertSame('token', $user->getToken()); + self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles()); + + $user->eraseCredentials(); + self::assertSame('hashed', $user->getPassword()); + + $user->setFirstname(null)->setLastname(null)->setTimezone(null)->setLocale(null)->setToken(null); + self::assertNull($user->getFirstname()); + self::assertNull($user->getLastname()); + self::assertNull($user->getTimezone()); + self::assertNull($user->getLocale()); + self::assertNull($user->getToken()); + } + + public function testUserIdentifierRequiresAnEmail(): void + { + $user = (new User())->setEmail(''); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The User class must have an email.'); + + $user->getUserIdentifier(); + } +} diff --git a/tests/EventSubscriber/ProcessEventSubscriberTest.php b/tests/EventSubscriber/ProcessEventSubscriberTest.php new file mode 100644 index 0000000..f35b76c --- /dev/null +++ b/tests/EventSubscriber/ProcessEventSubscriberTest.php @@ -0,0 +1,191 @@ + 'onProcessStart', + ProcessEvent::EVENT_PROCESS_ENDED => [['flushDoctrineLogs', 100], ['success', 100]], + ProcessEvent::EVENT_PROCESS_FAILED => [['flushDoctrineLogs', 100], ['fail', 100]], + ], + ProcessEventSubscriber::getSubscribedEvents() + ); + } + + public function testProcessStartCreatesTheProcessExecution(): void + { + $repository = $this->createMock(ProcessExecutionRepository::class); + $repository->expects(self::once())->method('save')->with(self::isInstanceOf(ProcessExecution::class)); + $processExecutionManager = new ProcessExecutionManager($repository); + $processHandler = new ProcessHandler('/var/log/process', $processExecutionManager); + + $this->createSubscriber($processHandler, $processExecutionManager) + ->onProcessStart(new ProcessEvent('test.process', null, ['foo' => 'bar'])); + + self::assertTrue($processHandler->hasFilename()); + self::assertMatchesRegularExpression( + '#^/var/log/process/test\.process/[0-9a-f-]{36}\.log$#', + (string) $processHandler->getFilename() + ); + + $processExecution = $processExecutionManager->getCurrentProcessExecution(); + self::assertInstanceOf(ProcessExecution::class, $processExecution); + self::assertSame('test.process', $processExecution->getCode()); + self::assertSame(basename((string) $processHandler->getFilename()), $processExecution->logFilename); + self::assertSame(['foo' => 'bar'], $processExecution->getContext()); + self::assertSame(ProcessExecutionStatus::Started, $processExecution->status); + } + + public function testProcessStartKeepsTheCurrentLogFile(): void + { + $processExecutionManager = new ProcessExecutionManager($this->createStub(ProcessExecutionRepository::class)); + $processHandler = new ProcessHandler('/var/log/process', $processExecutionManager); + $processHandler->setFilename('parent.process/parent.log'); + + $this->createSubscriber($processHandler, $processExecutionManager) + ->onProcessStart(new ProcessEvent('test.process')); + + self::assertSame('/var/log/process/parent.process/parent.log', $processHandler->getFilename()); + self::assertSame('parent.log', $processExecutionManager->getCurrentProcessExecution()?->logFilename); + } + + public function testSubProcessStartKeepsTheCurrentProcessExecution(): void + { + $repository = $this->createMock(ProcessExecutionRepository::class); + $repository->expects(self::never())->method('save'); + $processExecutionManager = new ProcessExecutionManager($repository); + $parent = new ProcessExecution('parent.process', 'parent.log'); + $processExecutionManager->setCurrentProcessExecution($parent); + + $this->createSubscriber(new ProcessHandler('/var/log/process', $processExecutionManager), $processExecutionManager) + ->onProcessStart(new ProcessEvent('test.process')); + + self::assertSame($parent, $processExecutionManager->getCurrentProcessExecution()); + } + + /** + * @return iterable + */ + public static function provideEnds(): iterable + { + yield 'success' => ['success', ProcessExecutionStatus::Finish]; + yield 'fail' => ['fail', ProcessExecutionStatus::Failed]; + } + + /** + * @param 'success'|'fail' $method + */ + #[DataProvider('provideEnds')] + public function testProcessEnd(string $method, ProcessExecutionStatus $expectedStatus): void + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + $repository = $this->createMock(ProcessExecutionRepository::class); + $repository->expects(self::once())->method('save')->with($processExecution); + $processExecutionManager = new ProcessExecutionManager($repository); + $processExecutionManager->setCurrentProcessExecution($processExecution); + $processHandler = new ProcessHandler('/var/log/process', $processExecutionManager); + $processHandler->setFilename('test.process/test.log'); + + $this->createSubscriber($processHandler, $processExecutionManager)->{$method}(new ProcessEvent('test.process')); + + self::assertSame($expectedStatus, $processExecution->status); + self::assertInstanceOf(\DateTimeImmutable::class, $processExecution->endDate); + self::assertNull($processExecutionManager->getCurrentProcessExecution()); + self::assertFalse($processHandler->hasFilename()); + } + + /** + * @param 'success'|'fail' $method + */ + #[TestWith(['success'])] + #[TestWith(['fail'])] + public function testSubProcessEndIsIgnored(string $method): void + { + $processExecution = new ProcessExecution('parent.process', 'parent.log'); + $repository = $this->createMock(ProcessExecutionRepository::class); + $repository->expects(self::never())->method('save'); + $processExecutionManager = new ProcessExecutionManager($repository); + $processExecutionManager->setCurrentProcessExecution($processExecution); + $processHandler = new ProcessHandler('/var/log/process', $processExecutionManager); + $processHandler->setFilename('parent.process/parent.log'); + + $this->createSubscriber($processHandler, $processExecutionManager)->{$method}(new ProcessEvent('test.process')); + + self::assertSame(ProcessExecutionStatus::Started, $processExecution->status); + self::assertNull($processExecution->endDate); + self::assertSame($processExecution, $processExecutionManager->getCurrentProcessExecution()); + self::assertTrue($processHandler->hasFilename()); + } + + public function testFlushDoctrineLogs(): void + { + $processExecutionManager = new ProcessExecutionManager($this->createStub(ProcessExecutionRepository::class)); + $processExecutionManager->setCurrentProcessExecution(new ProcessExecution('test.process', 'test.log')); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(LogRecord::class)); + $entityManager->expects(self::atLeastOnce())->method('flush'); + + $doctrineProcessHandler = new DoctrineProcessHandler(); + $doctrineProcessHandler->setEntityManager($entityManager); + $doctrineProcessHandler->setProcessExecutionManager($processExecutionManager); + $doctrineProcessHandler->handle( + new \Monolog\LogRecord(new \DateTimeImmutable(), 'cleverage_process', Level::Info, 'message') + ); + + $subscriber = new ProcessEventSubscriber( + new ProcessHandler('/var/log/process', $processExecutionManager), + $doctrineProcessHandler, + $processExecutionManager + ); + $subscriber->flushDoctrineLogs(new ProcessEvent('test.process')); + } + + private function createSubscriber( + ProcessHandler $processHandler, + ProcessExecutionManager $processExecutionManager, + ): ProcessEventSubscriber { + $doctrineProcessHandler = new DoctrineProcessHandler(); + $doctrineProcessHandler->disable(); + + return new ProcessEventSubscriber($processHandler, $doctrineProcessHandler, $processExecutionManager); + } +} diff --git a/tests/Form/Type/LaunchTypeTest.php b/tests/Form/Type/LaunchTypeTest.php new file mode 100644 index 0000000..560bfcc --- /dev/null +++ b/tests/Form/Type/LaunchTypeTest.php @@ -0,0 +1,169 @@ +dispatcher = new EventDispatcher(); + + parent::setUp(); + } + + /** + * @return list + */ + protected function getExtensions(): array + { + $registry = new ProcessConfigurationRegistry([ + 'text.process' => $this->rawProcess(), + 'file.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'file']], 'data'), + ], 'stop'); + + return [ + new PreloadedExtension([new LaunchType($registry, new ProcessConfigurationsManager($registry))], []), + new ValidatorExtension(Validation::createValidator()), + ]; + } + + public function testTextInput(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); + + self::assertInstanceOf(TextType::class, $this->getInnerType($form->get('input'))); + self::assertFalse($form->get('input')->getConfig()->getRequired(), 'The process has no entry point'); + + $context = $form->get('context')->getConfig(); + self::assertInstanceOf(CollectionType::class, $context->getType()->getInnerType()); + self::assertSame(ProcessContextType::class, $context->getOption('entry_type')); + self::assertTrue($context->getOption('allow_add')); + self::assertTrue($context->getOption('allow_delete')); + self::assertFalse($context->getOption('required')); + } + + public function testFileInput(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process']); + + self::assertInstanceOf(FileType::class, $this->getInnerType($form->get('input'))); + self::assertTrue($form->get('input')->getConfig()->getRequired(), 'The process has an entry point'); + } + + public function testSubmit(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); + $form->submit([ + 'input' => 'data.csv', + 'context' => [ + ['key' => 'foo', 'value' => 'bar'], + ['key' => 'baz', 'value' => 'qux'], + ], + ]); + + self::assertTrue($form->isSynchronized()); + self::assertTrue($form->isValid()); + self::assertSame(['input' => 'data.csv', 'context' => ['foo' => 'bar', 'baz' => 'qux']], $form->getData()); + } + + public function testSubmitWithoutContext(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); + $form->submit(['input' => '']); + + self::assertTrue($form->isValid()); + self::assertSame([], $form->get('context')->getData()); + } + + public function testDefaultData(): void + { + $form = $this->factory->create(LaunchType::class, ['input' => 'data.csv'], ['process_code' => 'text.process']); + + self::assertSame('data.csv', $form->get('input')->getData()); + } + + public function testInvalidContext(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); + $form->submit(['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => '']]]); + + self::assertTrue($form->isSubmitted()); + self::assertFalse($form->isValid()); + } + + public function testProcessCodeIsRequired(): void + { + $this->expectException(MissingOptionsException::class); + $this->factory->create(LaunchType::class); + } + + /** + * @param FormInterface $form + */ + private function getInnerType(FormInterface $form): object + { + return $form->getConfig()->getType()->getInnerType(); + } + + /** + * @param array $options + * + * @return array + */ + private function rawProcess(array $options = [], ?string $entryPoint = null): array + { + return [ + 'options' => $options, + 'entry_point' => $entryPoint, + 'end_point' => null, + 'description' => '', + 'help' => '', + 'public' => true, + 'tasks' => [ + 'data' => [ + 'service' => '@CleverAge\ProcessBundle\Task\DummyTask', + 'options' => [], + 'description' => '', + 'help' => '', + 'outputs' => [], + 'errors' => [], + 'error_outputs' => [], + 'error_strategy' => null, + 'log_level' => null, + ], + ], + ]; + } +} diff --git a/tests/Form/Type/ProcessContextTypeTest.php b/tests/Form/Type/ProcessContextTypeTest.php new file mode 100644 index 0000000..1484ebb --- /dev/null +++ b/tests/Form/Type/ProcessContextTypeTest.php @@ -0,0 +1,79 @@ +dispatcher = new EventDispatcher(); + + parent::setUp(); + } + + /** + * @return list + */ + protected function getExtensions(): array + { + return [new ValidatorExtension(Validation::createValidator())]; + } + + public function testFields(): void + { + $form = $this->factory->create(ProcessContextType::class); + + foreach (['key' => 'Context Key', 'value' => 'Context Value'] as $name => $label) { + $config = $form->get($name)->getConfig(); + self::assertSame($label, $config->getOption('label')); + self::assertSame(['placeholder' => $name], $config->getOption('attr')); + $constraints = $config->getOption('constraints'); + self::assertIsArray($constraints); + self::assertCount(1, $constraints); + self::assertInstanceOf(NotBlank::class, $constraints[0]); + } + } + + public function testSubmitValidData(): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => 'foo', 'value' => 'bar']); + + self::assertTrue($form->isSynchronized()); + self::assertTrue($form->isValid()); + self::assertSame(['key' => 'foo', 'value' => 'bar'], $form->getData()); + } + + #[TestWith(['', 'bar', 'key'])] + #[TestWith(['foo', '', 'value'])] + public function testKeyAndValueAreRequired(string $key, string $value, string $invalidField): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => $key, 'value' => $value]); + + self::assertFalse($form->isValid()); + self::assertCount(1, $form->get($invalidField)->getErrors()); + } +} diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php new file mode 100644 index 0000000..210fc59 --- /dev/null +++ b/tests/Functional/FunctionalTestCase.php @@ -0,0 +1,114 @@ +client = static::createClient(['debug' => false]); + $this->client->disableReboot(); + + $entityManager = $this->getEntityManager(); + $schemaTool = new SchemaTool($entityManager); + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + } + + protected static function getKernelClass(): string + { + return TestKernel::class; + } + + protected function getEntityManager(): EntityManagerInterface + { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get('doctrine.orm.entity_manager'); + + return $entityManager; + } + + /** + * @param list $roles + */ + protected function createUser(string $email = 'user@example.com', array $roles = [], string $password = 'password'): User + { + $user = new User(); + $user->setEmail($email); + $user->setRoles($roles); + $user->setPassword($password); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + + return $user; + } + + /** + * Messages dispatched to the execute_process transport (in memory in the test application). + * + * @return list + */ + protected function getDispatchedMessages(): array + { + /** @var InMemoryTransport $transport */ + $transport = static::getContainer()->get('messenger.transport.execute_process'); + + return array_values(array_map(static function (Envelope $envelope): ProcessExecuteMessage { + $message = $envelope->getMessage(); + self::assertInstanceOf(ProcessExecuteMessage::class, $message); + + return $message; + }, $transport->getSent())); + } + + /** + * @param list $roles + */ + protected function login(array $roles = []): User + { + $user = $this->createUser('user@example.com', $roles); + $this->client->loginUser($user, 'main'); + + return $user; + } + + /** + * Token displayed in the flash message of the "generateToken" action. + */ + protected function getGeneratedToken(string $flashMessage): string + { + if (1 !== preg_match('/New token generated (\w+)/', $flashMessage, $matches)) { + self::fail('No token in: '.$flashMessage); + } + + return $matches[1]; + } +} diff --git a/tests/Functional/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php new file mode 100644 index 0000000..6698aa5 --- /dev/null +++ b/tests/Functional/HttpProcessExecuteTest.php @@ -0,0 +1,195 @@ +createUser('api@example.com'); + $user->setToken((new Pbkdf2PasswordHasher())->hash(self::TOKEN)); + $this->getEntityManager()->flush(); + } + + public function testQueuedExecution(): void + { + $this->execute(['code' => 'test.process', 'input' => 'data', 'context' => ['key' => 'value']]); + + self::assertResponseIsSuccessful(); + self::assertSame('"Process has been added to queue. It will start as soon as possible."', $this->client->getResponse()->getContent()); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame('test.process', $messages[0]->code); + self::assertSame('data', $messages[0]->input); + self::assertSame(['key' => 'value'], $messages[0]->context); + } + + public function testSynchronousExecution(): void + { + $this->execute(['code' => 'test.process', 'queue' => '0']); + + self::assertResponseIsSuccessful(); + self::assertSame('"Process has been proceed well."', $this->client->getResponse()->getContent()); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testFailingSynchronousExecution(): void + { + $this->execute(['code' => 'test.failing', 'queue' => '0']); + + self::assertResponseStatusCodeSame(500); + self::assertStringContainsString('output', (string) $this->client->getResponse()->getContent()); + } + + public function testJsonBody(): void + { + $this->client->request( + 'POST', + '/http/process/execute', + server: ['HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN, 'CONTENT_TYPE' => 'application/json'], + content: json_encode(['code' => 'test.process', 'input' => 'data', 'context' => '{"key":"value"}', 'queue' => true], \JSON_THROW_ON_ERROR) + ); + + self::assertResponseIsSuccessful(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + // A JSON string context is decoded + self::assertSame(['key' => 'value'], $messages[0]->context); + } + + public function testUnknownProcess(): void + { + $this->execute(['code' => 'unknown']); + + self::assertResponseStatusCodeSame(422); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testInvalidToken(): void + { + $this->client->request('POST', '/http/process/execute', ['code' => 'test.process'], server: ['HTTP_AUTHORIZATION' => 'Bearer invalid']); + + self::assertResponseStatusCodeSame(401); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testMissingToken(): void + { + $this->client->request('POST', '/http/process/execute', ['code' => 'test.process']); + + self::assertResponseStatusCodeSame(401); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testTokenGeneratedInTheUi(): void + { + $admin = $this->login(['ROLE_ADMIN']); + $this->client->request('GET', '/process/user/'.$admin->getId().'/generate-token'); + self::assertResponseRedirects(); + $crawler = $this->client->followRedirect(); + $token = $this->getGeneratedToken($crawler->filter('.alert-success')->text()); + $this->getEntityManager()->clear(); + $user = $this->getEntityManager()->find(User::class, $admin->getId()); + self::assertInstanceOf(User::class, $user); + self::assertSame((new Pbkdf2PasswordHasher())->hash($token), $user->getToken()); + + $this->client->restart(); + $this->client->disableReboot(); + $this->client->request('POST', '/http/process/execute', ['code' => 'test.process'], server: ['HTTP_AUTHORIZATION' => 'Bearer '.$token]); + self::assertResponseIsSuccessful(); + } + + public function testSynchronousExecutionWithJsonContext(): void + { + $this->client->request( + 'POST', + '/http/process/execute', + server: ['HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN, 'CONTENT_TYPE' => 'application/json'], + content: json_encode(['code' => 'test.process', 'context' => '{"key":"value"}', 'queue' => false], \JSON_THROW_ON_ERROR) + ); + + self::assertResponseIsSuccessful(); + self::assertSame('"Process has been proceed well."', $this->client->getResponse()->getContent()); + } + + /** + * @param array $parameters + */ + private function execute(array $parameters): void + { + $this->client->request('POST', '/http/process/execute', $parameters, server: ['HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN]); + } +} diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php new file mode 100644 index 0000000..50c88bd --- /dev/null +++ b/tests/Functional/LaunchActionTest.php @@ -0,0 +1,167 @@ +login(); + + $this->client->request('GET', '/process?routeName=process_launch&process=test.process'); + + self::assertResponseRedirects(); + self::assertStringContainsString('routeName=process_list', (string) $this->client->getResponse()->headers->get('Location')); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame('test.process', $messages[0]->code); + self::assertNull($messages[0]->input); + self::assertSame(['execution_user' => 'user@example.com'], $messages[0]->context); + + $this->client->followRedirect(); + self::assertSelectorTextContains('.alert-success', 'Process has been added to queue'); + } + + public function testLaunchWithForm(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=process_launch&process=test.form'); + + self::assertResponseIsSuccessful(); + self::assertSame([], $this->getDispatchedMessages()); + // Default values of the UI options + self::assertSame('default input', $crawler->filter('input[name="launch[input]"]')->attr('value')); + self::assertSame('key1', $crawler->filter('input[name="launch[context][0][key]"]')->attr('value')); + + $form = $crawler->selectButton('Launch')->form(); + $values = $form->getPhpValues(); + $values['launch']['input'] = 'my input'; + $values['launch']['context'] = [['key' => 'key1', 'value' => 'value1'], ['key' => 'key2', 'value' => 'value2']]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame('test.form', $messages[0]->code); + self::assertSame('my input', $messages[0]->input); + self::assertSame(['execution_user' => 'user@example.com', 'key1' => 'value1', 'key2' => 'value2'], $messages[0]->context); + } + + public function testLaunchWithFileUpload(): void + { + $this->login(); + $file = tempnam(sys_get_temp_dir(), 'upload'); + file_put_contents((string) $file, 'line1'); + + $crawler = $this->client->request('GET', '/process?routeName=process_launch&process=test.upload'); + self::assertResponseIsSuccessful(); + self::assertCount(1, $crawler->filter('input[type="file"][name="launch[input]"]')); + + $form = $crawler->selectButton('Launch')->form(); + $this->client->request( + $form->getMethod(), + $form->getUri(), + $form->getPhpValues(), + ['launch' => ['input' => new UploadedFile((string) $file, 'data.csv', 'text/csv', null, true)]] + ); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + // The file is saved in the upload directory, its path is the process input + self::assertIsString($messages[0]->input); + self::assertStringEndsWith('.csv', $messages[0]->input); + $uploadDirectory = static::getContainer()->getParameter('upload_directory'); + self::assertIsString($uploadDirectory); + self::assertStringStartsWith($uploadDirectory.'/', $messages[0]->input); + self::assertSame('line1', file_get_contents($messages[0]->input)); + unlink((string) $file); + } + + public function testMissingProcessCode(): void + { + $this->login(); + + $this->client->request('GET', '/process?routeName=process_launch'); + + self::assertResponseStatusCodeSame(500); + } + + public function testUnknownProcess(): void + { + $this->login(); + + $this->client->request('GET', '/process?routeName=process_launch&process=unknown'); + + self::assertResponseStatusCodeSame(500); + self::assertSame([], $this->getDispatchedMessages()); + } +} diff --git a/tests/Functional/LogRecordCrudTest.php b/tests/Functional/LogRecordCrudTest.php new file mode 100644 index 0000000..42f0667 --- /dev/null +++ b/tests/Functional/LogRecordCrudTest.php @@ -0,0 +1,134 @@ +login(); + $first = $this->createExecution('test.process'); + $second = $this->createExecution('test.form'); + $this->createLogRecord($first, 'First process message', ['file' => 'a.csv']); + $this->createLogRecord($second, 'Second process message', []); + + $crawler = $this->client->request('GET', '/process/log-record'); + self::assertResponseIsSuccessful(); + self::assertCount(2, $crawler->filter('table tbody tr')); + + $crawler = $this->client->request('GET', '/process/log-record?filters[process][comparison]==&filters[process][value]='.$first->getId()); + self::assertResponseIsSuccessful(); + self::assertCount(1, $crawler->filter('table tbody tr')); + self::assertStringContainsString('First process message', $crawler->filter('table')->text()); + + $crawler = $this->client->request('GET', '/process/log-record?filters[level][comparison]==&filters[level][value]='.Level::Error->value); + self::assertResponseIsSuccessful(); + self::assertStringNotContainsString('process message', $crawler->filter('table')->text()); + + $this->client->request('GET', '/process/log-record/render-filters?filters[process][comparison]==&filters[process][value]='.$first->getId()); + self::assertResponseIsSuccessful(); + } + + public function testDetail(): void + { + $this->login(); + $record = $this->createLogRecord($this->createExecution('test.process'), 'A message', ['file' => 'a.csv']); + + $crawler = $this->client->request('GET', '/process/log-record/'.$record->getId()); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('A message', $crawler->text()); + self::assertStringContainsString('a.csv', $crawler->text()); + } + + private function createExecution(string $code): ProcessExecution + { + $execution = new ProcessExecution($code, $code.'.log'); + $this->getEntityManager()->persist($execution); + $this->getEntityManager()->flush(); + + return $execution; + } + + public function testEntityFqcn(): void + { + self::assertSame(LogRecord::class, LogRecordCrudController::getEntityFqcn()); + } + + /** + * @param array $context + */ + private function createLogRecord(ProcessExecution $execution, string $message, array $context): LogRecord + { + $record = new LogRecord(new \Monolog\LogRecord(new \DateTimeImmutable(), 'cleverage_process', Level::Warning, $message, $context), $execution); + $this->getEntityManager()->persist($record); + $this->getEntityManager()->flush(); + + return $record; + } +} diff --git a/tests/Functional/ProcessExecutionCrudTest.php b/tests/Functional/ProcessExecutionCrudTest.php new file mode 100644 index 0000000..1444e6d --- /dev/null +++ b/tests/Functional/ProcessExecutionCrudTest.php @@ -0,0 +1,181 @@ +login(); + $this->createExecution('test.process', ProcessExecutionStatus::Finish); + $this->createExecution('test.form', ProcessExecutionStatus::Failed); + + $crawler = $this->client->request('GET', '/process/process-execution'); + + self::assertResponseIsSuccessful(); + $rows = $crawler->filter('table tbody tr'); + self::assertCount(2, $rows); + self::assertStringContainsString('test.process', $crawler->filter('table')->text()); + self::assertStringContainsString('test.form', $crawler->filter('table')->text()); + } + + public function testFilters(): void + { + $this->login(); + $this->createExecution('test.process', ProcessExecutionStatus::Finish); + $this->createExecution('test.form', ProcessExecutionStatus::Finish); + + $crawler = $this->client->request('GET', '/process/process-execution?filters[code][comparison]=like&filters[code][value]=form'); + self::assertResponseIsSuccessful(); + self::assertCount(1, $crawler->filter('table tbody tr')); + + $crawler = $this->client->request('GET', '/process/process-execution?filters[duration][comparison]=>&filters[duration][value]=3600'); + self::assertResponseIsSuccessful(); + self::assertStringNotContainsString('test.form', $crawler->filter('table')->text()); + + $this->client->request('GET', '/process/process-execution/render-filters'); + self::assertResponseIsSuccessful(); + } + + public function testActionsDependOnTheLogs(): void + { + $this->login(); + $withLogs = $this->createExecution('test.process', ProcessExecutionStatus::Finish); + $this->createLogRecord($withLogs, 'A log message'); + $this->writeLogFile($withLogs, 'log file content'); + $this->createExecution('test.form', ProcessExecutionStatus::Finish); + + $crawler = $this->client->request('GET', '/process/process-execution'); + + self::assertResponseIsSuccessful(); + self::assertCount(1, $crawler->filter('a[href*="show-logs"]')); + self::assertCount(1, $crawler->filter('a[href*="download-logs"]')); + } + + public function testShowLogs(): void + { + $this->login(); + $execution = $this->createExecution('test.process', ProcessExecutionStatus::Finish); + + $this->client->request('GET', '/process/process-execution/show-logs?entityId='.$execution->getId()); + + self::assertResponseRedirects(); + $location = urldecode((string) $this->client->getResponse()->headers->get('Location')); + self::assertStringContainsString('/process/log-record', $location); + self::assertStringContainsString('filters[process][value]='.$execution->getId(), $location); + } + + public function testDownloadLogFile(): void + { + $this->login(); + $execution = $this->createExecution('test.process', ProcessExecutionStatus::Finish); + $this->writeLogFile($execution, 'log file content'); + + $this->client->request('GET', '/process/process-execution/download-logs?entityId='.$execution->getId()); + + self::assertResponseIsSuccessful(); + self::assertSame('log file content', $this->client->getInternalResponse()->getContent()); + self::assertResponseHeaderSame('Content-Type', 'text/plain; charset=utf-8'); + self::assertResponseHeaderSame('Content-Disposition', 'attachment; filename="'.$execution->logFilename.'"'); + } + + private function createExecution(string $code, ProcessExecutionStatus $status): ProcessExecution + { + $execution = new ProcessExecution($code, $code.'_'.uniqid().'.log', ['key' => 'value']); + $execution->setStatus($status); + $execution->addReport('count', 3); + $execution->end(); + $this->getEntityManager()->persist($execution); + $this->getEntityManager()->flush(); + + return $execution; + } + + private function createLogRecord(ProcessExecution $execution, string $message): void + { + $this->getEntityManager()->persist(new LogRecord(new \Monolog\LogRecord(new \DateTimeImmutable(), 'cleverage_process', Level::Warning, $message, ['file' => 'a.csv']), $execution)); + $this->getEntityManager()->flush(); + } + + public function testEntityFqcn(): void + { + self::assertSame(ProcessExecution::class, ProcessExecutionCrudController::getEntityFqcn()); + } + + private function writeLogFile(ProcessExecution $execution, string $content): void + { + /** @var string $logDir */ + $logDir = static::getContainer()->getParameter('kernel.logs_dir'); + (new Filesystem())->dumpFile($logDir.'/'.$execution->code.'/'.$execution->logFilename, $content); + } +} diff --git a/tests/Functional/ProcessListTest.php b/tests/Functional/ProcessListTest.php new file mode 100644 index 0000000..be8753d --- /dev/null +++ b/tests/Functional/ProcessListTest.php @@ -0,0 +1,114 @@ +login(); + + $this->client->request('GET', '/process'); + + self::assertResponseRedirects(); + self::assertStringContainsString('/process/process-execution', (string) $this->client->getResponse()->headers->get('Location')); + } + + public function testPublicProcessesAreListed(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=process_list'); + + self::assertResponseIsSuccessful(); + $text = $crawler->filter('table')->text(); + self::assertStringContainsString('test.process', $text); + self::assertStringContainsString('test.form', $text); + self::assertStringNotContainsString('test.private', $text); + } + + public function testUsersMenuIsReservedToAdmins(): void + { + $this->login(); + $crawler = $this->client->request('GET', '/process?routeName=process_list'); + self::assertStringNotContainsString('User List', $crawler->filter('nav, aside, .sidebar, body')->first()->text()); + + $this->client->restart(); + $this->client->disableReboot(); + $this->client->loginUser($this->createUser('admin@example.com', ['ROLE_ADMIN']), 'main'); + $crawler = $this->client->request('GET', '/process?routeName=process_list'); + self::assertStringContainsString('User List', $crawler->filter('body')->text()); + } + + public function testUserLocale(): void + { + $user = $this->createUser('fr@example.com'); + $user->setLocale('fr'); + $this->getEntityManager()->flush(); + $this->client->loginUser($user, 'main'); + + $crawler = $this->client->request('GET', '/process?routeName=process_list'); + + self::assertResponseIsSuccessful(); + // EasyAdmin labels translated in the user locale + self::assertStringContainsString('Déconnexion', $crawler->filter('body')->text()); + } +} diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php new file mode 100644 index 0000000..df16bab --- /dev/null +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -0,0 +1,169 @@ +login(); + $this->createSchedule(ProcessScheduleType::CRON, '0 4 * * *'); + $this->createSchedule(ProcessScheduleType::EVERY, '10 seconds'); + + $crawler = $this->client->request('GET', '/process/process-schedule'); + + self::assertResponseIsSuccessful(); + self::assertCount(2, $crawler->filter('table tbody tr')); + // No "messenger:consume scheduler_cron" process in the test environment + self::assertSelectorTextContains('.alert-warning', 'bin/console messenger:consume scheduler_cron'); + // The next execution is only computed for CRON expressions + self::assertMatchesRegularExpression('/\d{4}-\d{2}-\d{2}T04:00:00/', $crawler->filter('table')->text()); + } + + public function testCreate(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process/process-schedule/new'); + self::assertResponseIsSuccessful(); + // Only the public processes can be scheduled + $options = $crawler->filter('select[name="ProcessSchedule[process]"] option')->each(static fn ($option): string => (string) $option->attr('value')); + self::assertContains('test.process', $options); + self::assertNotContains('test.private', $options); + + $form = $crawler->selectButton('Create')->form(); + $values = $form->getPhpValues(); + $values['ProcessSchedule']['process'] = 'test.process'; + $values['ProcessSchedule']['type'] = 'cron'; + $values['ProcessSchedule']['expression'] = '*/5 * * * *'; + $values['ProcessSchedule']['input'] = 'my input'; + $values['ProcessSchedule']['context'] = [['key' => 'key1', 'value' => 'value1']]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $schedules = $this->getEntityManager()->getRepository(ProcessSchedule::class)->findAll(); + self::assertCount(1, $schedules); + self::assertSame('test.process', $schedules[0]->getProcess()); + self::assertSame(ProcessScheduleType::CRON, $schedules[0]->getType()); + self::assertSame('*/5 * * * *', $schedules[0]->getExpression()); + self::assertSame('my input', $schedules[0]->getInput()); + } + + public function testInvalidExpressionIsRejected(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process/process-schedule/new'); + $form = $crawler->selectButton('Create')->form(); + $values = $form->getPhpValues(); + $values['ProcessSchedule']['process'] = 'test.process'; + $values['ProcessSchedule']['type'] = 'cron'; + $values['ProcessSchedule']['expression'] = 'not a cron expression'; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseStatusCodeSame(422); + self::assertSame([], $this->getEntityManager()->getRepository(ProcessSchedule::class)->findAll()); + } + + public function testEdit(): void + { + $this->login(); + $schedule = $this->createSchedule(ProcessScheduleType::EVERY, '10 seconds'); + + $crawler = $this->client->request('GET', '/process/process-schedule/'.$schedule->getId().'/edit'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Save changes')->form(); + $values = $form->getPhpValues(); + $values['ProcessSchedule']['expression'] = '1 hour'; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $this->getEntityManager()->clear(); + self::assertSame('1 hour', $this->getEntityManager()->find(ProcessSchedule::class, $schedule->getId())?->getExpression()); + } + + public function testEntityFqcn(): void + { + self::assertSame(ProcessSchedule::class, ProcessScheduleCrudController::getEntityFqcn()); + } + + private function createSchedule(ProcessScheduleType $type, string $expression): ProcessSchedule + { + $schedule = new ProcessSchedule(); + $schedule->setProcess('test.process'); + $schedule->setType($type); + $schedule->setExpression($expression); + $this->getEntityManager()->persist($schedule); + $this->getEntityManager()->flush(); + + return $schedule; + } +} diff --git a/tests/Functional/SecurityTest.php b/tests/Functional/SecurityTest.php new file mode 100644 index 0000000..2dd0eff --- /dev/null +++ b/tests/Functional/SecurityTest.php @@ -0,0 +1,142 @@ + + */ + public static function provideProtectedUrls(): iterable + { + yield 'dashboard' => ['/process']; + yield 'process list' => ['/process?routeName=process_list']; + yield 'process launch' => ['/process?routeName=process_launch&process=test.process']; + yield 'executions' => ['/process/process-execution']; + yield 'logs' => ['/process/log-record']; + yield 'scheduler' => ['/process/process-schedule']; + yield 'new schedule' => ['/process/process-schedule/new']; + yield 'users' => ['/process/user']; + yield 'new user' => ['/process/user/new']; + } + + #[DataProvider('provideProtectedUrls')] + public function testAnonymousIsRedirectedToLogin(string $url): void + { + $this->client->request('GET', $url); + + self::assertResponseRedirects('http://localhost/process/login'); + } + + public function testLogin(): void + { + $this->createUser('admin@example.com', ['ROLE_ADMIN'], 'secret'); + + $crawler = $this->client->request('GET', '/process/login'); + self::assertResponseIsSuccessful(); + $form = $crawler->filter('form')->form(['_username' => 'admin@example.com', '_password' => 'secret']); + $this->client->submit($form); + + self::assertResponseRedirects('/process'); + $this->client->followRedirect(); + self::assertResponseRedirects(); + $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + } + + public function testLoginWithAnInvalidPassword(): void + { + $this->createUser('admin@example.com', ['ROLE_ADMIN'], 'secret'); + + $crawler = $this->client->request('GET', '/process/login'); + $form = $crawler->filter('form')->form(['_username' => 'admin@example.com', '_password' => 'invalid']); + $this->client->submit($form); + + self::assertResponseRedirects('http://localhost/process/login'); + $this->client->request('GET', '/process'); + self::assertResponseRedirects('http://localhost/process/login'); + } + + public function testLogout(): void + { + $this->login(); + + $this->client->request('GET', '/process/logout'); + self::assertResponseRedirects('http://localhost/process/login'); + + $this->client->request('GET', '/process'); + self::assertResponseRedirects('http://localhost/process/login'); + } +} diff --git a/tests/Functional/UserCrudTest.php b/tests/Functional/UserCrudTest.php new file mode 100644 index 0000000..1c993e0 --- /dev/null +++ b/tests/Functional/UserCrudTest.php @@ -0,0 +1,144 @@ +login(['ROLE_ADMIN']); + $this->createUser('other@example.com'); + + $crawler = $this->client->request('GET', '/process/user'); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('other@example.com', $crawler->filter('table')->text()); + } + + public function testUsersAreReservedToAdmins(): void + { + $this->login(); + $other = $this->createUser('other@example.com'); + + $crawler = $this->client->request('GET', '/process/user'); + // The users are filtered by the entity permission + self::assertStringNotContainsString('other@example.com', $crawler->text()); + + $this->client->request('GET', '/process/user/'.$other->getId().'/edit'); + self::assertResponseStatusCodeSame(403); + } + + public function testCreate(): void + { + $this->login(['ROLE_ADMIN']); + + $crawler = $this->client->request('GET', '/process/user/new'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Create')->form(); + $values = $form->getPhpValues(); + $values['User']['email'] = 'new@example.com'; + $values['User']['password'] = ['first' => 'new password', 'second' => 'new password']; + $values['User']['firstname'] = 'Jane'; + $values['User']['roles'] = ['ROLE_ADMIN']; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $user = $this->getEntityManager()->getRepository(User::class)->findOneBy(['email' => 'new@example.com']); + self::assertInstanceOf(User::class, $user); + self::assertSame('Jane', $user->getFirstname()); + self::assertContains('ROLE_ADMIN', $user->getRoles()); + // Hashed with the hasher of the test application (plaintext) + self::assertSame('new password', $user->getPassword()); + } + + public function testPasswordsMustMatch(): void + { + $this->login(['ROLE_ADMIN']); + + $crawler = $this->client->request('GET', '/process/user/new'); + $form = $crawler->selectButton('Create')->form(); + $values = $form->getPhpValues(); + $values['User']['email'] = 'new@example.com'; + $values['User']['password'] = ['first' => 'password', 'second' => 'other']; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseStatusCodeSame(422); + self::assertNull($this->getEntityManager()->getRepository(User::class)->findOneBy(['email' => 'new@example.com'])); + } + + public function testEntityFqcn(): void + { + self::assertSame(User::class, UserCrudController::getEntityFqcn()); + } + + public function testGenerateToken(): void + { + $admin = $this->login(['ROLE_ADMIN']); + + $crawler = $this->client->request('GET', '/process/user/'.$admin->getId().'/edit'); + self::assertResponseIsSuccessful(); + self::assertCount(1, $crawler->filter('a[href*="generate-token"]')); + + $this->client->request('GET', '/process/user/'.$admin->getId().'/generate-token'); + self::assertResponseRedirects(); + self::assertStringContainsString('/process/user/'.$admin->getId().'/edit', (string) $this->client->getResponse()->headers->get('Location')); + $crawler = $this->client->followRedirect(); + $token = $this->getGeneratedToken($crawler->filter('.alert-success')->text()); + $this->getEntityManager()->clear(); + // Only the hash of the token is stored + self::assertSame((new Pbkdf2PasswordHasher())->hash($token), $this->getEntityManager()->find(User::class, $admin->getId())?->getToken()); + } +} diff --git a/tests/Http/Model/HttpProcessExecutionTest.php b/tests/Http/Model/HttpProcessExecutionTest.php new file mode 100644 index 0000000..640aed7 --- /dev/null +++ b/tests/Http/Model/HttpProcessExecutionTest.php @@ -0,0 +1,108 @@ +code); + self::assertNull($execution->input); + self::assertSame([], $execution->context); + self::assertTrue($execution->queue); + } + + public function testConstruct(): void + { + $execution = new HttpProcessExecution('demo.process', 'input', '{"key":"value"}', false); + + self::assertSame('demo.process', $execution->code); + self::assertSame('input', $execution->input); + self::assertSame('{"key":"value"}', $execution->context); + self::assertFalse($execution->queue); + } + + /** + * @param string|array $context + * @param list $expectedViolations + */ + #[DataProvider('provideValidation')] + public function testValidation(?string $code, string|array $context, array $expectedViolations): void + { + $registry = $this->createStub(ProcessConfigurationRegistry::class); + $registry->method('hasProcessConfiguration') + ->willReturnCallback(static fn (string $code): bool => 'demo.process' === $code); + $registry->method('getProcessConfiguration')->willReturn(new ProcessConfiguration('demo.process', [])); + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory(new ConstraintValidatorFactory([ + IsValidProcessCodeValidator::class => new IsValidProcessCodeValidator($registry), + ])) + ->getValidator(); + + $violations = array_map( + static fn (ConstraintViolationInterface $violation): string => $violation->getPropertyPath().': '.$violation->getMessage(), + iterator_to_array($validator->validate(new HttpProcessExecution($code, null, $context))) + ); + + self::assertSame($expectedViolations, $violations); + } + + /** + * @return iterable, list}> + */ + public static function provideValidation(): iterable + { + yield 'array context' => ['demo.process', ['key' => 'value'], []]; + yield 'json context' => ['demo.process', '{"key":"value"}', []]; + yield 'missing code' => [null, [], ['code: Process code is required.']]; + yield 'unknown code' => ['demo.unknown', [], ['code: The process "demo.unknown" does not exist.']]; + } + + public function testInvalidJsonContext(): void + { + $registry = $this->createStub(ProcessConfigurationRegistry::class); + $registry->method('hasProcessConfiguration')->willReturn(true); + $registry->method('getProcessConfiguration')->willReturn(new ProcessConfiguration('demo.process', [])); + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory(new ConstraintValidatorFactory([ + IsValidProcessCodeValidator::class => new IsValidProcessCodeValidator($registry), + ])) + ->getValidator(); + + $violations = $validator->validate(new HttpProcessExecution('demo.process', null, '{invalid')); + + self::assertCount(1, $violations); + self::assertSame('context', $violations->get(0)->getPropertyPath()); + } +} diff --git a/tests/Http/ValueResolver/HttpProcessExecuteValueResolverTest.php b/tests/Http/ValueResolver/HttpProcessExecuteValueResolverTest.php new file mode 100644 index 0000000..0e8e540 --- /dev/null +++ b/tests/Http/ValueResolver/HttpProcessExecuteValueResolverTest.php @@ -0,0 +1,157 @@ +storageDir = sys_get_temp_dir().'/'.uniqid('http_value_resolver_test_', true); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->storageDir); + } + + public function testEmptyRequest(): void + { + $execution = $this->resolve(Request::create('/http/process/execute', 'POST')); + + self::assertEquals(new HttpProcessExecution(), $execution); + } + + public function testJsonContent(): void + { + $request = Request::create('/http/process/execute', 'POST', content: (string) json_encode([ + 'code' => 'test.process', + 'input' => 'data.csv', + 'context' => ['foo' => 'bar'], + 'queue' => false, + ])); + + self::assertEquals( + new HttpProcessExecution('test.process', 'data.csv', ['foo' => 'bar'], false), + $this->resolve($request) + ); + } + + public function testInvalidJsonContent(): void + { + $request = Request::create('/http/process/execute', 'POST', content: '{"code": '); + + self::assertEquals(new HttpProcessExecution(), $this->resolve($request)); + } + + public function testFormData(): void + { + $request = Request::create('/http/process/execute', 'POST', [ + 'code' => 'test.process', + 'input' => 'data.csv', + 'context' => ['foo' => 'bar'], + 'queue' => '0', + ]); + + self::assertEquals( + new HttpProcessExecution('test.process', 'data.csv', ['foo' => 'bar'], false), + $this->resolve($request) + ); + } + + public function testFormDataIsQueuedByDefault(): void + { + $request = Request::create('/http/process/execute', 'POST', ['code' => 'test.process']); + + self::assertEquals(new HttpProcessExecution('test.process', null, [], true), $this->resolve($request)); + } + + public function testQueryParametersWithFormData(): void + { + $request = Request::create( + '/http/process/execute?code=test.process&input=data.csv&context[foo]=bar', + 'POST', + ['queue' => '1'] + ); + + self::assertEquals( + new HttpProcessExecution('test.process', 'data.csv', ['foo' => 'bar'], true), + $this->resolve($request) + ); + } + + public function testUploadedFileInput(): void + { + $file = tempnam(sys_get_temp_dir(), 'upload'); + self::assertIsString($file); + file_put_contents($file, 'id;name'); + $request = Request::create( + '/http/process/execute', + 'POST', + ['code' => 'test.process'], + files: ['input' => new UploadedFile($file, 'data.csv', 'text/csv', null, true)] + ); + + $execution = $this->resolve($request); + unlink($file); + + self::assertSame('test.process', $execution->code); + self::assertIsString($execution->input); + self::assertStringStartsWith($this->storageDir.\DIRECTORY_SEPARATOR, $execution->input); + self::assertStringEndsWith('_data.csv', $execution->input); + self::assertSame('id;name', file_get_contents($execution->input)); + } + + public function testInvalidFormDataContext(): void + { + // The context must be an array in form data + $request = Request::create('/http/process/execute', 'POST', ['code' => 'test.process', 'context' => 'foo']); + + self::assertEquals(new HttpProcessExecution(), $this->resolve($request)); + } + + private function resolve(Request $request): HttpProcessExecution + { + $resolver = new HttpProcessExecuteValueResolver( + $this->storageDir, + new Serializer([new ObjectNormalizer()], [new JsonEncoder()]) + ); + $values = $resolver->resolve( + $request, + new ArgumentMetadata('httpProcessExecution', HttpProcessExecution::class, false, false, null) + ); + $values = \is_array($values) ? $values : iterator_to_array($values, false); + + self::assertCount(1, $values); + self::assertInstanceOf(HttpProcessExecution::class, $values[0]); + + return $values[0]; + } +} diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php new file mode 100644 index 0000000..f0f9b8a --- /dev/null +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -0,0 +1,158 @@ +createManager([ + 'public.one' => $this->rawProcess(), + 'private.one' => $this->rawProcess(public: false), + 'public.two' => $this->rawProcess(), + ]); + + $codes = static fn (array $configurations): array => array_values( + array_map(static fn (ProcessConfiguration $configuration): string => $configuration->getCode(), $configurations) + ); + + self::assertSame(['public.one', 'public.two'], $codes($manager->getPublicProcesses())); + self::assertSame(['private.one'], $codes($manager->getPrivateProcesses())); + } + + public function testUiOptionsOfAnUnknownProcess(): void + { + self::assertNull($this->createManager(['test.process' => $this->rawProcess()])->getUiOptions('unknown')); + } + + public function testDefaultUiOptions(): void + { + $options = $this->createManager(['test.process' => $this->rawProcess()])->getUiOptions('test.process'); + + self::assertNotNull($options); + self::assertNull($options['source']); + self::assertNull($options['target']); + self::assertSame('text', $options['entrypoint_type']); + self::assertSame('modal', $options['ui_launch_mode']); + self::assertSame([], $options['constraints']); + /** @var mixed $run the PHPDoc type of the option is wrong */ + $run = $options['run']; + self::assertNull($run); + // The "default" nested option is not asserted: defined with setDefault() and a closure, it is not resolved as + // a nested option with symfony/options-resolver 8 (the closure itself is returned) + self::assertArrayHasKey('default', $options); + } + + public function testConfiguredUiOptions(): void + { + $manager = $this->createManager([ + 'test.process' => $this->rawProcess([ + 'ui' => [ + 'source' => 'source', + 'target' => 'target', + 'entrypoint_type' => 'file', + 'ui_launch_mode' => 'form', + 'constraints' => [['NotBlank' => null], ['Length' => ['max' => 10]]], + 'run' => true, + 'default' => [ + 'input' => 'data.csv', + 'context' => [['key' => 'foo', 'value' => 'bar']], + ], + ], + ]), + ]); + + $options = $manager->getUiOptions('test.process'); + + self::assertNotNull($options); + self::assertSame('source', $options['source']); + self::assertSame('target', $options['target']); + self::assertSame('file', $options['entrypoint_type']); + self::assertSame('form', $options['ui_launch_mode']); + /** @var mixed $run the PHPDoc type of the option is wrong */ + $run = $options['run']; + self::assertTrue($run); + self::assertSame(['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => 'bar']]], $options['default']); + self::assertCount(2, $options['constraints']); + self::assertInstanceOf(NotBlank::class, $options['constraints'][0]); + self::assertInstanceOf(Length::class, $options['constraints'][1]); + self::assertSame(10, $options['constraints'][1]->max); + } + + public function testInvalidEntrypointType(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'csv']])]); + + $this->expectException(InvalidOptionsException::class); + $manager->getUiOptions('test.process'); + } + + public function testInvalidLaunchMode(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'popup']])]); + + $this->expectException(InvalidOptionsException::class); + $manager->getUiOptions('test.process'); + } + + /** + * @param array> $rawConfiguration + */ + private function createManager(array $rawConfiguration): ProcessConfigurationsManager + { + return new ProcessConfigurationsManager(new ProcessConfigurationRegistry($rawConfiguration, 'stop')); + } + + /** + * Raw configuration of a process with a single task, as the process bundle configuration would give it. + * + * @param array $options + * + * @return array + */ + private function rawProcess(array $options = [], bool $public = true): array + { + return [ + 'options' => $options, + 'entry_point' => null, + 'end_point' => null, + 'description' => '', + 'help' => '', + 'public' => $public, + 'tasks' => [ + 'data' => [ + 'service' => '@CleverAge\ProcessBundle\Task\DummyTask', + 'options' => [], + 'description' => '', + 'help' => '', + 'outputs' => [], + 'errors' => [], + 'error_outputs' => [], + 'error_strategy' => null, + 'log_level' => null, + ], + ], + ]; + } +} diff --git a/tests/Manager/ProcessExecutionManagerTest.php b/tests/Manager/ProcessExecutionManagerTest.php new file mode 100644 index 0000000..f0370e8 --- /dev/null +++ b/tests/Manager/ProcessExecutionManagerTest.php @@ -0,0 +1,94 @@ +createMock(ProcessExecutionRepository::class); + $repository->expects(self::never())->method('save'); + + $manager = new ProcessExecutionManager($repository); + $manager->increment('Warning'); + $manager->setReport('key', 'value'); + + self::assertNull($manager->getCurrentProcessExecution()); + self::assertSame($manager, $manager->save()); + self::assertSame($manager, $manager->unsetProcessExecution('test.process')); + } + + public function testTheFirstProcessExecutionIsKept(): void + { + $first = new ProcessExecution('first', 'first.log'); + $manager = new ProcessExecutionManager($this->createStub(ProcessExecutionRepository::class)); + + self::assertSame($manager, $manager->setCurrentProcessExecution($first)); + $manager->setCurrentProcessExecution(new ProcessExecution('second', 'second.log')); + + self::assertSame($first, $manager->getCurrentProcessExecution()); + } + + public function testUnsetProcessExecutionOnlyForItsCode(): void + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + $manager = new ProcessExecutionManager($this->createStub(ProcessExecutionRepository::class)); + $manager->setCurrentProcessExecution($processExecution); + + $manager->unsetProcessExecution('other.process'); + self::assertSame($processExecution, $manager->getCurrentProcessExecution()); + + $manager->unsetProcessExecution('test.process'); + self::assertNull($manager->getCurrentProcessExecution()); + + $other = new ProcessExecution('other.process', 'other.log'); + $manager->setCurrentProcessExecution($other); + self::assertSame($other, $manager->getCurrentProcessExecution()); + } + + public function testSaveTheCurrentProcessExecution(): void + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + $repository = $this->createMock(ProcessExecutionRepository::class); + $repository->expects(self::once())->method('save')->with($processExecution); + + $manager = new ProcessExecutionManager($repository); + $manager->setCurrentProcessExecution($processExecution); + + self::assertSame($manager, $manager->save()); + } + + public function testReports(): void + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + $manager = new ProcessExecutionManager($this->createStub(ProcessExecutionRepository::class)); + $manager->setCurrentProcessExecution($processExecution); + + $manager->increment('Warning'); + $manager->increment('Warning'); + $manager->increment('Error', 5); + $manager->setReport('file', 'data.csv'); + + self::assertSame(['Warning' => 2, 'Error' => 5, 'file' => 'data.csv'], $processExecution->getReport()); + } +} diff --git a/tests/Message/CronProcessMessageHandlerTest.php b/tests/Message/CronProcessMessageHandlerTest.php new file mode 100644 index 0000000..ad3980d --- /dev/null +++ b/tests/Message/CronProcessMessageHandlerTest.php @@ -0,0 +1,83 @@ +, array, ?string}> + */ + public static function provideSchedules(): iterable + { + yield 'without context nor input' => [[], [], null]; + yield 'with context and input' => [ + [['key' => 'foo', 'value' => 'bar'], ['key' => 'baz', 'value' => 'qux']], + ['foo' => 'bar', 'baz' => 'qux'], + 'data.csv', + ]; + yield 'last value of a duplicated key wins' => [ + [['key' => 'foo', 'value' => 'bar'], ['key' => 'foo', 'value' => 'baz']], + ['foo' => 'baz'], + null, + ]; + } + + /** + * @param list $context + * @param array $expectedContext + */ + #[DataProvider('provideSchedules')] + public function testDispatchProcessExecuteMessage(array $context, array $expectedContext, ?string $input): void + { + $schedule = (new ProcessSchedule()) + ->setProcess('test.process') + ->setType(ProcessScheduleType::CRON) + ->setExpression('* * * * *') + ->setInput($input); + $schedule->setContext($context); + + $dispatched = null; + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::once()) + ->method('dispatch') + ->willReturnCallback(static function (object $message) use (&$dispatched): Envelope { + $dispatched = $message; + + return new Envelope($message); + }); + + (new CronProcessMessageHandler($bus))(new CronProcessMessage($schedule)); + + self::assertInstanceOf(ProcessExecuteMessage::class, $dispatched); + self::assertSame('test.process', $dispatched->code); + self::assertSame($input, $dispatched->input); + self::assertSame($expectedContext, $dispatched->context); + } +} diff --git a/tests/Message/CronProcessMessageTest.php b/tests/Message/CronProcessMessageTest.php new file mode 100644 index 0000000..2c071cc --- /dev/null +++ b/tests/Message/CronProcessMessageTest.php @@ -0,0 +1,30 @@ +processSchedule); + } +} diff --git a/tests/Message/ProcessExecuteHandlerTest.php b/tests/Message/ProcessExecuteHandlerTest.php new file mode 100644 index 0000000..bc849b3 --- /dev/null +++ b/tests/Message/ProcessExecuteHandlerTest.php @@ -0,0 +1,51 @@ +createStub(ProcessExecutionManager::class)); + // Log file of a previous execution, in the same worker + $processHandler->setFilename('previous.log'); + + $manager = $this->createMock(ProcessManager::class); + $manager->expects(self::once()) + ->method('execute') + ->with('test.process', 'data.csv', ['foo' => 'bar']) + ->willReturnCallback(static function () use ($processHandler): mixed { + // The log file of the previous execution is closed before the new execution + self::assertFalse($processHandler->hasFilename()); + + return null; + }); + + (new ProcessExecuteHandler($manager, $processHandler))( + new ProcessExecuteMessage('test.process', 'data.csv', ['foo' => 'bar']) + ); + } +} diff --git a/tests/Message/ProcessExecuteMessageTest.php b/tests/Message/ProcessExecuteMessageTest.php new file mode 100644 index 0000000..fd77697 --- /dev/null +++ b/tests/Message/ProcessExecuteMessageTest.php @@ -0,0 +1,39 @@ + 'value']); + + self::assertSame('demo.process', $message->code); + self::assertSame(['input'], $message->input); + self::assertSame(['key' => 'value'], $message->context); + } + + public function testContextDefaultsToEmptyArray(): void + { + $message = new ProcessExecuteMessage('demo.process', null); + + self::assertNull($message->input); + self::assertSame([], $message->context); + } +} diff --git a/tests/Migrations/MigrationTestCase.php b/tests/Migrations/MigrationTestCase.php new file mode 100644 index 0000000..4d30da7 --- /dev/null +++ b/tests/Migrations/MigrationTestCase.php @@ -0,0 +1,87 @@ + + */ + public static function provideMySqlPlatforms(): iterable + { + yield 'MySQL' => [new MySQLPlatform()]; + yield 'MariaDB' => [new MariaDBPlatform()]; + } + + /** + * @return iterable + */ + public static function providePlatforms(): iterable + { + yield from self::provideMySqlPlatforms(); + yield 'PostgreSQL' => [new PostgreSQLPlatform()]; + yield 'SQLite' => [new SQLitePlatform()]; + } + + /** + * @param class-string $class + */ + protected function createMigration(string $class, ?AbstractPlatform $platform = null): AbstractMigration + { + $connection = $this->createStub(Connection::class); + $connection->method('getDatabasePlatform')->willReturn($platform ?? new MySQLPlatform()); + + return new $class($connection, new NullLogger()); + } + + /** + * @return list + */ + protected function getStatements(AbstractMigration $migration): array + { + return array_values(array_map(static fn (Query $query): string => $query->getStatement(), $migration->getSql())); + } + + /** + * @param array> $tables table name => column names + */ + protected function createSchema(array $tables): Schema + { + $schema = new Schema(); + foreach ($tables as $name => $columns) { + $table = $schema->createTable($name); + $table->addColumn('id', 'integer'); + foreach ($columns as $column) { + $table->addColumn($column, 'string'); + } + } + + return $schema; + } +} diff --git a/tests/Migrations/Version20231006111525Test.php b/tests/Migrations/Version20231006111525Test.php new file mode 100644 index 0000000..e756c3d --- /dev/null +++ b/tests/Migrations/Version20231006111525Test.php @@ -0,0 +1,131 @@ +createMigration(Version20231006111525::class)->getDescription() + ); + } + + #[DataProvider('provideMySqlPlatforms')] + public function testUpOnMySql(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20231006111525::class, $platform); + $migration->up($this->createSchema([])); + + $statements = $this->getStatements($migration); + self::assertCount(4, $statements); + self::assertStringStartsWith('CREATE TABLE log_record (id INT AUTO_INCREMENT NOT NULL, process_execution_id INT DEFAULT NULL,', $statements[0]); + self::assertStringStartsWith('CREATE TABLE process_execution (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(255) NOT NULL,', $statements[1]); + self::assertSame(self::FOREIGN_KEY, $statements[2]); + self::assertStringStartsWith('CREATE TABLE process_user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL,', $statements[3]); + self::assertStringContainsString('UNIQUE INDEX UNIQ_627A047CE7927C74 (email)', $statements[3]); + foreach ([0, 1, 3] as $index) { + self::assertStringEndsWith('DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB', $statements[$index]); + } + } + + public function testUpOnPostgreSql(): void + { + $migration = $this->createMigration(Version20231006111525::class, new PostgreSQLPlatform()); + $migration->up($this->createSchema([])); + + $statements = $this->getStatements($migration); + self::assertCount(17, $statements); + self::assertSame('CREATE SEQUENCE log_record_id_seq INCREMENT BY 1 MINVALUE 1 START 1', $statements[0]); + self::assertStringStartsWith('CREATE TABLE log_record (id INT NOT NULL,', $statements[1]); + self::assertSame('CREATE SEQUENCE process_execution_id_seq INCREMENT BY 1 MINVALUE 1 START 1', $statements[6]); + self::assertStringStartsWith('CREATE TABLE process_execution (id INT NOT NULL,', $statements[7]); + self::assertSame(self::FOREIGN_KEY.' NOT DEFERRABLE INITIALLY IMMEDIATE', $statements[12]); + self::assertSame('CREATE SEQUENCE process_user_id_seq INCREMENT BY 1 MINVALUE 1 START 1', $statements[13]); + self::assertStringStartsWith('CREATE TABLE process_user (id INT NOT NULL,', $statements[14]); + self::assertSame('CREATE UNIQUE INDEX UNIQ_627A047CE7927C74 ON process_user (email)', $statements[15]); + self::assertSame('CREATE INDEX idx_process_user_email ON process_user (email)', $statements[16]); + foreach ($statements as $statement) { + self::assertStringNotContainsString('AUTO_INCREMENT', $statement); + self::assertStringNotContainsString('ENGINE', $statement); + } + } + + #[DataProvider('providePlatforms')] + public function testUpDoesNothingWhenTheTablesExist(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20231006111525::class, $platform); + $migration->up($this->createSchema(['log_record' => [], 'process_execution' => [], 'process_user' => []])); + + self::assertSame([], $this->getStatements($migration)); + } + + #[DataProvider('provideMySqlPlatforms')] + public function testUpOnlyCreatesMissingTablesOnMySql(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20231006111525::class, $platform); + $migration->up($this->createSchema(['log_record' => [], 'process_execution' => []])); + + $statements = $this->getStatements($migration); + self::assertCount(1, $statements); + self::assertStringStartsWith('CREATE TABLE process_user (', $statements[0]); + } + + public function testUpOnlyCreatesMissingTablesOnPostgreSql(): void + { + $migration = $this->createMigration(Version20231006111525::class, new PostgreSQLPlatform()); + $migration->up($this->createSchema(['log_record' => [], 'process_user' => []])); + + $statements = $this->getStatements($migration); + self::assertCount(7, $statements); + self::assertSame('CREATE SEQUENCE process_execution_id_seq INCREMENT BY 1 MINVALUE 1 START 1', $statements[0]); + self::assertSame(self::FOREIGN_KEY.' NOT DEFERRABLE INITIALLY IMMEDIATE', $statements[6]); + } + + public function testUpDoesNothingOnOtherPlatforms(): void + { + $migration = $this->createMigration(Version20231006111525::class, new SQLitePlatform()); + $migration->up($this->createSchema([])); + + self::assertSame([], $this->getStatements($migration)); + } + + #[DataProvider('providePlatforms')] + public function testDown(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20231006111525::class, $platform); + $migration->down($this->createSchema(['log_record' => [], 'process_execution' => [], 'process_user' => []])); + + self::assertSame( + [ + 'ALTER TABLE log_record DROP CONSTRAINT FK_8ECECC333DAC0075', + 'DROP TABLE log_record', + 'DROP TABLE process_execution', + 'DROP TABLE process_user', + ], + $this->getStatements($migration) + ); + } +} diff --git a/tests/Migrations/Version20240729151928Test.php b/tests/Migrations/Version20240729151928Test.php new file mode 100644 index 0000000..e6be25f --- /dev/null +++ b/tests/Migrations/Version20240729151928Test.php @@ -0,0 +1,72 @@ +createMigration(Version20240729151928::class)->getDescription()); + } + + #[DataProvider('provideMySqlPlatforms')] + public function testUpOnMySql(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20240729151928::class, $platform); + $migration->up($this->createSchema([])); + + $statements = $this->getStatements($migration); + self::assertCount(1, $statements); + self::assertStringStartsWith('CREATE TABLE process_schedule (', $statements[0]); + foreach (['process VARCHAR(255) NOT NULL', 'type VARCHAR(6) NOT NULL', 'expression VARCHAR(255) NOT NULL', 'input VARCHAR(255)', 'context JSON NOT NULL', 'ENGINE = InnoDB'] as $part) { + self::assertStringContainsString($part, $statements[0]); + } + } + + public function testUpOnPostgreSql(): void + { + $migration = $this->createMigration(Version20240729151928::class, new PostgreSQLPlatform()); + $migration->up($this->createSchema([])); + + self::assertSame( + ['CREATE TABLE process_schedule (id INT AUTO_INCREMENT NOT NULL, process VARCHAR(255) NOT NULL, type VARCHAR(6) NOT NULL, expression VARCHAR(255) NOT NULL, input VARCHAR(255), context JSON NOT NULL, PRIMARY KEY(id))'], + $this->getStatements($migration) + ); + } + + public function testUpDoesNothingOnOtherPlatforms(): void + { + $migration = $this->createMigration(Version20240729151928::class, new SQLitePlatform()); + $migration->up($this->createSchema([])); + + self::assertSame([], $this->getStatements($migration)); + } + + #[DataProvider('providePlatforms')] + public function testDown(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20240729151928::class, $platform); + $migration->down($this->createSchema(['process_schedule' => []])); + + self::assertSame(['DROP TABLE process_schedule'], $this->getStatements($migration)); + } +} diff --git a/tests/Migrations/Version20240730090403Test.php b/tests/Migrations/Version20240730090403Test.php new file mode 100644 index 0000000..f1875e8 --- /dev/null +++ b/tests/Migrations/Version20240730090403Test.php @@ -0,0 +1,46 @@ +createMigration(Version20240730090403::class)->getDescription()); + } + + #[DataProvider('providePlatforms')] + public function testUp(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20240730090403::class, $platform); + $migration->up($this->createSchema(['process_user' => []])); + + self::assertSame(['ALTER TABLE process_user ADD token VARCHAR(255) DEFAULT NULL'], $this->getStatements($migration)); + } + + #[DataProvider('providePlatforms')] + public function testDown(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20240730090403::class, $platform); + $migration->down($this->createSchema(['process_user' => ['token']])); + + self::assertSame(['ALTER TABLE process_user DROP token'], $this->getStatements($migration)); + } +} diff --git a/tests/Migrations/Version20241007134542Test.php b/tests/Migrations/Version20241007134542Test.php new file mode 100644 index 0000000..29acca6 --- /dev/null +++ b/tests/Migrations/Version20241007134542Test.php @@ -0,0 +1,78 @@ +createMigration(Version20241007134542::class)->getDescription()); + } + + #[DataProvider('providePlatforms')] + public function testUpAddsTheColumn(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20241007134542::class, $platform); + $migration->up($this->createSchema(['process_user' => []])); + + self::assertSame(['ALTER TABLE process_user ADD timezone VARCHAR(255) DEFAULT NULL'], $this->getStatements($migration)); + } + + public function testUpDoesNothingWhenTheColumnExists(): void + { + $migration = $this->createMigration(Version20241007134542::class); + $migration->up($this->createSchema(['process_user' => ['timezone']])); + + self::assertSame([], $this->getStatements($migration)); + } + + public function testUpDoesNothingWithoutTheTable(): void + { + $migration = $this->createMigration(Version20241007134542::class); + $migration->up($this->createSchema([])); + + self::assertSame([], $this->getStatements($migration)); + } + + #[DataProvider('providePlatforms')] + public function testDownDropsTheColumn(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20241007134542::class, $platform); + $migration->down($this->createSchema(['process_user' => ['timezone']])); + + self::assertSame(['ALTER TABLE process_user DROP timezone'], $this->getStatements($migration)); + } + + public function testDownDoesNothingWithoutTheColumn(): void + { + $migration = $this->createMigration(Version20241007134542::class); + $migration->down($this->createSchema(['process_user' => []])); + + self::assertSame([], $this->getStatements($migration)); + } + + public function testDownDoesNothingWithoutTheTable(): void + { + $migration = $this->createMigration(Version20241007134542::class); + $migration->down($this->createSchema([])); + + self::assertSame([], $this->getStatements($migration)); + } +} diff --git a/tests/Migrations/Version20241007152613Test.php b/tests/Migrations/Version20241007152613Test.php new file mode 100644 index 0000000..564b351 --- /dev/null +++ b/tests/Migrations/Version20241007152613Test.php @@ -0,0 +1,78 @@ +createMigration(Version20241007152613::class)->getDescription()); + } + + #[DataProvider('providePlatforms')] + public function testUpAddsTheColumn(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20241007152613::class, $platform); + $migration->up($this->createSchema(['process_execution' => []])); + + self::assertSame(['ALTER TABLE process_execution ADD context JSON NOT NULL'], $this->getStatements($migration)); + } + + public function testUpDoesNothingWhenTheColumnExists(): void + { + $migration = $this->createMigration(Version20241007152613::class); + $migration->up($this->createSchema(['process_execution' => ['context']])); + + self::assertSame([], $this->getStatements($migration)); + } + + public function testUpDoesNothingWithoutTheTable(): void + { + $migration = $this->createMigration(Version20241007152613::class); + $migration->up($this->createSchema([])); + + self::assertSame([], $this->getStatements($migration)); + } + + #[DataProvider('providePlatforms')] + public function testDownDropsTheColumn(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20241007152613::class, $platform); + $migration->down($this->createSchema(['process_execution' => ['context']])); + + self::assertSame(['ALTER TABLE process_execution DROP context'], $this->getStatements($migration)); + } + + public function testDownDoesNothingWithoutTheColumn(): void + { + $migration = $this->createMigration(Version20241007152613::class); + $migration->down($this->createSchema(['process_execution' => []])); + + self::assertSame([], $this->getStatements($migration)); + } + + public function testDownDoesNothingWithoutTheTable(): void + { + $migration = $this->createMigration(Version20241007152613::class); + $migration->down($this->createSchema([])); + + self::assertSame([], $this->getStatements($migration)); + } +} diff --git a/tests/Migrations/Version20241009075733Test.php b/tests/Migrations/Version20241009075733Test.php new file mode 100644 index 0000000..bcfec99 --- /dev/null +++ b/tests/Migrations/Version20241009075733Test.php @@ -0,0 +1,78 @@ +createMigration(Version20241009075733::class)->getDescription()); + } + + #[DataProvider('providePlatforms')] + public function testUpAddsTheColumn(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20241009075733::class, $platform); + $migration->up($this->createSchema(['process_user' => []])); + + self::assertSame(['ALTER TABLE process_user ADD locale VARCHAR(255) DEFAULT NULL'], $this->getStatements($migration)); + } + + public function testUpDoesNothingWhenTheColumnExists(): void + { + $migration = $this->createMigration(Version20241009075733::class); + $migration->up($this->createSchema(['process_user' => ['locale']])); + + self::assertSame([], $this->getStatements($migration)); + } + + public function testUpDoesNothingWithoutTheTable(): void + { + $migration = $this->createMigration(Version20241009075733::class); + $migration->up($this->createSchema([])); + + self::assertSame([], $this->getStatements($migration)); + } + + #[DataProvider('providePlatforms')] + public function testDownDropsTheColumn(AbstractPlatform $platform): void + { + $migration = $this->createMigration(Version20241009075733::class, $platform); + $migration->down($this->createSchema(['process_user' => ['locale']])); + + self::assertSame(['ALTER TABLE process_user DROP locale'], $this->getStatements($migration)); + } + + public function testDownDoesNothingWithoutTheColumn(): void + { + $migration = $this->createMigration(Version20241009075733::class); + $migration->down($this->createSchema(['process_user' => []])); + + self::assertSame([], $this->getStatements($migration)); + } + + public function testDownDoesNothingWithoutTheTable(): void + { + $migration = $this->createMigration(Version20241009075733::class); + $migration->down($this->createSchema([])); + + self::assertSame([], $this->getStatements($migration)); + } +} diff --git a/tests/Monolog/Handler/DoctrineProcessHandlerTest.php b/tests/Monolog/Handler/DoctrineProcessHandlerTest.php new file mode 100644 index 0000000..7e8bf08 --- /dev/null +++ b/tests/Monolog/Handler/DoctrineProcessHandlerTest.php @@ -0,0 +1,143 @@ + $persisted */ + $persisted = new \ArrayObject(); + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::exactly(2)) + ->method('persist') + ->willReturnCallback(static function (object $entity) use ($persisted): void { + self::assertInstanceOf(LogRecord::class, $entity); + $persisted[] = $entity; + }); + $entityManager->expects(self::exactly(2))->method('flush'); + + $handler = $this->createHandler($entityManager, $processExecution); + $handler->handle($this->createRecord(Level::Info, 'first')); + $handler->handle($this->createRecord(Level::Error, 'second')); + self::assertCount(0, $persisted, 'Records are kept in memory until the flush'); + + $handler->flush(); + self::assertCount(2, $persisted); + [$first, $second] = $persisted->getArrayCopy(); + self::assertSame('first', $first->message); + self::assertSame(Level::Info->value, $first->level); + self::assertSame($processExecution, $first->getProcessExecution()); + self::assertSame('second', $second->message); + self::assertSame(Level::Error->value, $second->level); + + // Records are flushed only once + $handler->flush(); + self::assertCount(2, $persisted); + $handler->disable(); // no flush on destruction + } + + public function testRecordsAreFlushedEvery500Records(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::exactly(500))->method('persist'); + $entityManager->expects(self::once())->method('flush'); + + $handler = $this->createHandler($entityManager, new ProcessExecution('test.process', 'test.log')); + for ($i = 0; $i < 501; ++$i) { + $handler->handle($this->createRecord(Level::Debug, 'message '.$i)); + } + $handler->disable(); // the 501st record is not flushed on destruction + } + + public function testRecordsAreFlushedOnDestruction(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(LogRecord::class)); + $entityManager->expects(self::once())->method('flush'); + + $handler = $this->createHandler($entityManager, new ProcessExecution('test.process', 'test.log')); + $handler->handle($this->createRecord(Level::Warning, 'message')); + unset($handler); + } + + public function testRecordsAreDroppedWithoutProcessExecution(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('persist'); + $entityManager->expects(self::once())->method('flush'); + + $handler = $this->createHandler($entityManager, null); + $handler->handle($this->createRecord(Level::Info, 'message')); + $handler->flush(); + $handler->disable(); + } + + public function testDisabledHandler(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('persist'); + $entityManager->expects(self::never())->method('flush'); + + $handler = $this->createHandler($entityManager, new ProcessExecution('test.process', 'test.log')); + $handler->handle($this->createRecord(Level::Info, 'before')); + $handler->disable(); + $handler->handle($this->createRecord(Level::Info, 'after')); + $handler->flush(); + } + + public function testWithoutEntityManagerNorProcessExecutionManager(): void + { + $handler = new DoctrineProcessHandler(Level::Info, false); + + self::assertFalse($handler->isHandling($this->createRecord(Level::Debug, 'debug'))); + self::assertTrue($handler->handle($this->createRecord(Level::Info, 'message')), 'The record does not bubble'); + $handler->flush(); + } + + private function createHandler(EntityManagerInterface $entityManager, ?ProcessExecution $processExecution): DoctrineProcessHandler + { + $processExecutionManager = new ProcessExecutionManager($this->createStub(ProcessExecutionRepository::class)); + if ($processExecution instanceof ProcessExecution) { + $processExecutionManager->setCurrentProcessExecution($processExecution); + } + + $handler = new DoctrineProcessHandler(); + $handler->setEntityManager($entityManager); + $handler->setProcessExecutionManager($processExecutionManager); + + return $handler; + } + + private function createRecord(Level $level, string $message): \Monolog\LogRecord + { + return new \Monolog\LogRecord(new \DateTimeImmutable(), 'cleverage_process', $level, $message); + } +} diff --git a/tests/Monolog/Handler/ProcessHandlerTest.php b/tests/Monolog/Handler/ProcessHandlerTest.php index f39c6e5..8f2f927 100644 --- a/tests/Monolog/Handler/ProcessHandlerTest.php +++ b/tests/Monolog/Handler/ProcessHandlerTest.php @@ -77,6 +77,18 @@ public function testNothingIsWrittenWithoutFilename(): void self::assertSame([], glob($this->directory.'/*')); } + public function testFilename(): void + { + $handler = new ProcessHandler($this->directory, $this->createStub(ProcessExecutionManager::class)); + self::assertNull($handler->getFilename()); + + $handler->setFilename('process.log'); + self::assertSame($this->directory.'/process.log', $handler->getFilename()); + + $handler->close(); + self::assertNull($handler->getFilename()); + } + private function createRecord(Level $level): LogRecord { return new LogRecord(new \DateTimeImmutable(), 'cleverage_process', $level, strtolower($level->name).' message'); diff --git a/tests/Repository/ProcessExecutionRepositoryTest.php b/tests/Repository/ProcessExecutionRepositoryTest.php new file mode 100644 index 0000000..5d2c792 --- /dev/null +++ b/tests/Repository/ProcessExecutionRepositoryTest.php @@ -0,0 +1,139 @@ + false]); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get('doctrine.orm.entity_manager'); + $this->entityManager = $entityManager; + $schemaTool = new SchemaTool($entityManager); + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + + $this->repository = new ProcessExecutionRepository($entityManager); + } + + protected static function getKernelClass(): string + { + return TestKernel::class; + } + + public function testSave(): void + { + $processExecution = new ProcessExecution('test.process', 'test.log', ['foo' => 'bar']); + $this->repository->save($processExecution); + + self::assertNotNull($processExecution->getId()); + $this->entityManager->clear(); + + $saved = $this->repository->find($processExecution->getId()); + self::assertInstanceOf(ProcessExecution::class, $saved); + self::assertNotSame($processExecution, $saved); + self::assertSame('test.process', $saved->getCode()); + self::assertSame('test.log', $saved->logFilename); + self::assertSame(['foo' => 'bar'], $saved->getContext()); + } + + public function testGetLastProcessExecution(): void + { + self::assertNull($this->repository->getLastProcessExecution('test.process')); + + $old = $this->createProcessExecution('test.process', '2024-01-01 10:00:00'); + $last = $this->createProcessExecution('test.process', '2024-01-03 10:00:00'); + $this->createProcessExecution('test.process', '2024-01-02 10:00:00'); + $this->createProcessExecution('other.process', '2024-01-04 10:00:00'); + $this->entityManager->clear(); + + $lastProcessExecution = $this->repository->getLastProcessExecution('test.process'); + self::assertInstanceOf(ProcessExecution::class, $lastProcessExecution); + self::assertSame($last, $lastProcessExecution->getId()); + self::assertNotSame($old, $last); + self::assertNull($this->repository->getLastProcessExecution('unknown.process')); + } + + public function testGetLastProcessExecutionWithAQuoteInTheCode(): void + { + $id = $this->createProcessExecution("it's.process", '2024-01-01 10:00:00'); + $this->createProcessExecution('its.process', '2024-01-02 10:00:00'); + + self::assertSame($id, $this->repository->getLastProcessExecution("it's.process")?->getId()); + } + + public function testHasLogs(): void + { + $withLogs = new ProcessExecution('test.process', 'with_logs.log'); + $withoutLogs = new ProcessExecution('test.process', 'without_logs.log'); + $this->repository->save($withoutLogs); + $this->entityManager->persist(new LogRecord( + new \Monolog\LogRecord(new \DateTimeImmutable(), 'cleverage_process', Level::Info, 'message'), + $withLogs + )); + $this->entityManager->flush(); + + self::assertTrue($this->repository->hasLogs($withLogs)); + self::assertFalse($this->repository->hasLogs($withoutLogs)); + } + + /** + * Saves a process execution started at the given date (the start date is always "now" in the entity). + */ + private function createProcessExecution(string $code, string $startDate): int + { + $processExecution = new ProcessExecution($code, uniqid('', true).'.log'); + $this->repository->save($processExecution); + $id = (int) $processExecution->getId(); + + $this->entityManager->getConnection()->update( + 'process_execution', + ['start_date' => new \DateTimeImmutable($startDate)], + ['id' => $id], + ['start_date' => Types::DATETIME_IMMUTABLE] + ); + + return $id; + } +} diff --git a/tests/Repository/ProcessScheduleRepositoryTest.php b/tests/Repository/ProcessScheduleRepositoryTest.php new file mode 100644 index 0000000..75cc0ed --- /dev/null +++ b/tests/Repository/ProcessScheduleRepositoryTest.php @@ -0,0 +1,86 @@ + false]); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get('doctrine.orm.entity_manager'); + $this->entityManager = $entityManager; + $schemaTool = new SchemaTool($entityManager); + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + } + + protected static function getKernelClass(): string + { + return TestKernel::class; + } + + public function testRepositoryOfTheEntity(): void + { + self::assertInstanceOf(ProcessScheduleRepository::class, $this->entityManager->getRepository(ProcessSchedule::class)); + } + + public function testFindSchedules(): void + { + $schedule = (new ProcessSchedule()) + ->setProcess('test.process') + ->setType(ProcessScheduleType::EVERY) + ->setExpression('1 hour') + ->setInput('data.csv'); + $schedule->setContext([['key' => 'foo', 'value' => 'bar']]); + $this->entityManager->persist($schedule); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $repository = new ProcessScheduleRepository($this->entityManager); + $schedules = $repository->findAll(); + + self::assertCount(1, $schedules); + self::assertSame($schedule->getId(), $schedules[0]->getId()); + self::assertSame('test.process', $schedules[0]->getProcess()); + self::assertSame(ProcessScheduleType::EVERY, $schedules[0]->getType()); + self::assertSame('1 hour', $schedules[0]->getExpression()); + self::assertSame('data.csv', $schedules[0]->getInput()); + self::assertSame([['key' => 'foo', 'value' => 'bar']], $schedules[0]->getContext()); + } +} diff --git a/tests/Scheduler/CronSchedulerTest.php b/tests/Scheduler/CronSchedulerTest.php new file mode 100644 index 0000000..c59a27d --- /dev/null +++ b/tests/Scheduler/CronSchedulerTest.php @@ -0,0 +1,157 @@ +createMock(LoggerInterface::class); + $logger->expects(self::never())->method(self::anything()); + + $scheduler = new CronScheduler($this->createRepository([]), $this->createValidator(), $logger); + + self::assertSame([], $scheduler->getSchedule()->getRecurringMessages()); + } + + public function testCronAndEverySchedules(): void + { + $cron = $this->createSchedule(ProcessScheduleType::CRON, '*/5 * * * *'); + $every = $this->createSchedule(ProcessScheduleType::EVERY, '10 seconds'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::never())->method(self::anything()); + + $scheduler = new CronScheduler($this->createRepository([$cron, $every]), $this->createValidator(), $logger); + $messages = array_values($scheduler->getSchedule()->getRecurringMessages()); + + self::assertCount(2, $messages); + + $cronTrigger = $messages[0]->getTrigger(); + self::assertInstanceOf(CronExpressionTrigger::class, $cronTrigger); + self::assertSame('*/5 * * * *', (string) $cronTrigger); + self::assertSame($cron, $this->getScheduledMessage($messages[0])->processSchedule); + + self::assertInstanceOf(PeriodicalTrigger::class, $messages[1]->getTrigger()); + self::assertStringContainsString('10 seconds', (string) $messages[1]->getTrigger()); + self::assertSame($every, $this->getScheduledMessage($messages[1])->processSchedule); + } + + public function testInvalidSchedulesAreSkipped(): void + { + $invalid = $this->createSchedule(ProcessScheduleType::CRON, 'invalid'); + $valid = $this->createSchedule(ProcessScheduleType::EVERY, '1 hour'); + + $violations = new ConstraintViolationList([ + new ConstraintViolation('First reason', null, [], $invalid, 'expression', 'invalid'), + new ConstraintViolation('Second reason', null, [], $invalid, 'process', 'unknown'), + ]); + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects(self::exactly(2)) + ->method('validate') + ->willReturnCallback(static fn (mixed $value): ConstraintViolationList => $value === $invalid ? $violations : new ConstraintViolationList()); + + $logged = []; + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::exactly(2)) + ->method('info') + ->willReturnCallback(static function (string|\Stringable $message, array $context) use (&$logged): void { + $logged[] = [(string) $message, $context]; + }); + + $scheduler = new CronScheduler($this->createRepository([$invalid, $valid]), $validator, $logger); + $messages = array_values($scheduler->getSchedule()->getRecurringMessages()); + + self::assertSame( + [ + ['Scheduler configuration is not valid.', ['reason' => 'First reason']], + ['Scheduler configuration is not valid.', ['reason' => 'Second reason']], + ], + $logged + ); + self::assertCount(1, $messages); + self::assertSame($valid, $this->getScheduledMessage($messages[0])->processSchedule); + } + + public function testExceptionsAreLogged(): void + { + $repository = $this->createStub(ProcessScheduleRepository::class); + $repository->method('findAll')->willThrowException(new \RuntimeException('Database is not available')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('critical')->with('Database is not available'); + + $scheduler = new CronScheduler($repository, $this->createValidator(), $logger); + + self::assertSame([], $scheduler->getSchedule()->getRecurringMessages()); + } + + /** + * @param list $schedules + */ + private function createRepository(array $schedules): ProcessScheduleRepository + { + $repository = $this->createStub(ProcessScheduleRepository::class); + $repository->method('findAll')->willReturn($schedules); + + return $repository; + } + + private function createValidator(): ValidatorInterface + { + $validator = $this->createStub(ValidatorInterface::class); + $validator->method('validate')->willReturn(new ConstraintViolationList()); + + return $validator; + } + + private function createSchedule(ProcessScheduleType $type, string $expression): ProcessSchedule + { + return (new ProcessSchedule()) + ->setProcess('test.process') + ->setType($type) + ->setExpression($expression); + } + + private function getScheduledMessage(RecurringMessage $recurringMessage): CronProcessMessage + { + $context = new MessageContext('default', $recurringMessage->getId(), $recurringMessage->getTrigger(), new \DateTimeImmutable()); + $messages = iterator_to_array($recurringMessage->getMessages($context), false); + + self::assertCount(1, $messages); + self::assertInstanceOf(CronProcessMessage::class, $messages[0]); + + return $messages[0]; + } +} diff --git a/tests/Twig/Components/BootstrapModalTest.php b/tests/Twig/Components/BootstrapModalTest.php new file mode 100644 index 0000000..882a658 --- /dev/null +++ b/tests/Twig/Components/BootstrapModalTest.php @@ -0,0 +1,41 @@ +id); + self::assertNull($modal->title); + self::assertNull($modal->message); + self::assertNull($modal->confirmUrl); + + $modal->id = 'modal-id'; + $modal->title = 'Title'; + $modal->message = 'Are you sure?'; + $modal->confirmUrl = '/confirm'; + + self::assertSame('modal-id', $modal->id); + self::assertSame('Title', $modal->title); + self::assertSame('Are you sure?', $modal->message); + self::assertSame('/confirm', $modal->confirmUrl); + } +} diff --git a/tests/Twig/Extension/LogLevelExtensionTest.php b/tests/Twig/Extension/LogLevelExtensionTest.php new file mode 100644 index 0000000..866fe37 --- /dev/null +++ b/tests/Twig/Extension/LogLevelExtensionTest.php @@ -0,0 +1,38 @@ + [$function->getName(), $function->getCallable()], + (new LogLevelExtension())->getFunctions() + ); + + self::assertSame([ + ['log_label', [LogLevelExtensionRuntime::class, 'getLabel']], + ['log_translation', [LogLevelExtensionRuntime::class, 'getTranslation']], + ['log_css_class', [LogLevelExtensionRuntime::class, 'getCssClass']], + ], $functions); + } +} diff --git a/tests/Twig/Extension/MD5ExtensionTest.php b/tests/Twig/Extension/MD5ExtensionTest.php new file mode 100644 index 0000000..fa0f105 --- /dev/null +++ b/tests/Twig/Extension/MD5ExtensionTest.php @@ -0,0 +1,34 @@ + [$filter->getName(), $filter->getCallable()], + (new MD5Extension())->getFilters() + ); + + self::assertSame([['md5', [MD5ExtensionRuntime::class, 'md5']]], $filters); + } +} diff --git a/tests/Twig/Extension/ProcessExecutionExtensionTest.php b/tests/Twig/Extension/ProcessExecutionExtensionTest.php new file mode 100644 index 0000000..556dd47 --- /dev/null +++ b/tests/Twig/Extension/ProcessExecutionExtensionTest.php @@ -0,0 +1,38 @@ + [$function->getName(), $function->getCallable()], + (new ProcessExecutionExtension())->getFunctions() + ); + + self::assertSame([ + ['get_last_execution_date', [ProcessExecutionExtensionRuntime::class, 'getLastExecutionDate']], + ['get_process_source', [ProcessExecutionExtensionRuntime::class, 'getProcessSource']], + ['get_process_target', [ProcessExecutionExtensionRuntime::class, 'getProcessTarget']], + ], $functions); + } +} diff --git a/tests/Twig/Extension/ProcessExtensionTest.php b/tests/Twig/Extension/ProcessExtensionTest.php new file mode 100644 index 0000000..be1f20f --- /dev/null +++ b/tests/Twig/Extension/ProcessExtensionTest.php @@ -0,0 +1,34 @@ + [$function->getName(), $function->getCallable()], + (new ProcessExtension())->getFunctions() + ); + + self::assertSame([['resolve_ui_options', [ProcessExtensionRuntime::class, 'getUiOptions']]], $functions); + } +} diff --git a/tests/Twig/Runtime/LogLevelExtensionRuntimeTest.php b/tests/Twig/Runtime/LogLevelExtensionRuntimeTest.php new file mode 100644 index 0000000..0f615c7 --- /dev/null +++ b/tests/Twig/Runtime/LogLevelExtensionRuntimeTest.php @@ -0,0 +1,86 @@ +getLabel(Level::Warning->value)); + self::assertSame('DEBUG', $runtime->getLabel(100)); + } + + public function testGetLabelOfUnknownLevel(): void + { + $this->expectException(\ValueError::class); + + (new LogLevelExtensionRuntime())->getLabel(42); + } + + public function testGetTranslation(): void + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with('enum.log_level.warning', [], 'enums') + ->willReturn('Avertissement'); + + $runtime = new LogLevelExtensionRuntime(); + $runtime->setTranslator($translator); + + self::assertSame('Avertissement', $runtime->getTranslation('WARNING')); + } + + #[DataProvider('provideCssClass')] + public function testGetCssClass(Level $level, string $expected): void + { + $runtime = new LogLevelExtensionRuntime(); + + self::assertSame($expected, $runtime->getCssClass($level->value)); + self::assertSame($expected, $runtime->getCssClass($level->name)); + } + + /** + * @return iterable + */ + public static function provideCssClass(): iterable + { + yield 'debug' => [Level::Debug, 'success']; + yield 'info' => [Level::Info, 'success']; + yield 'notice' => [Level::Notice, '']; + yield 'warning' => [Level::Warning, 'warning']; + yield 'error' => [Level::Error, 'danger']; + yield 'critical' => [Level::Critical, 'danger']; + yield 'alert' => [Level::Alert, 'danger']; + yield 'emergency' => [Level::Emergency, 'danger']; + } + + public function testGetCssClassOfUnknownValue(): void + { + $runtime = new LogLevelExtensionRuntime(); + + self::assertSame('', $runtime->getCssClass(42)); + self::assertSame('', $runtime->getCssClass('WARNING')); + } +} diff --git a/tests/Twig/Runtime/MD5ExtensionRuntimeTest.php b/tests/Twig/Runtime/MD5ExtensionRuntimeTest.php new file mode 100644 index 0000000..3220307 --- /dev/null +++ b/tests/Twig/Runtime/MD5ExtensionRuntimeTest.php @@ -0,0 +1,30 @@ +md5('')); + self::assertSame('5d41402abc4b2a76b9719d911017c592', $runtime->md5('hello')); + } +} diff --git a/tests/Twig/Runtime/ProcessExecutionExtensionRuntimeTest.php b/tests/Twig/Runtime/ProcessExecutionExtensionRuntimeTest.php new file mode 100644 index 0000000..c224863 --- /dev/null +++ b/tests/Twig/Runtime/ProcessExecutionExtensionRuntimeTest.php @@ -0,0 +1,74 @@ +createMock(ProcessExecutionRepository::class); + $repository->expects(self::exactly(2)) + ->method('getLastProcessExecution') + ->willReturnMap([['demo.process', $execution], ['demo.unknown', null]]); + + $runtime = new ProcessExecutionExtensionRuntime($repository, $this->createManager()); + + self::assertSame($execution, $runtime->getLastExecutionDate('demo.process')); + self::assertNull($runtime->getLastExecutionDate('demo.unknown')); + } + + public function testGetProcessSourceAndTarget(): void + { + $runtime = new ProcessExecutionExtensionRuntime( + $this->createStub(ProcessExecutionRepository::class), + $this->createManager() + ); + + self::assertSame('Pim', $runtime->getProcessSource('demo.with_ui')); + self::assertSame('Shop', $runtime->getProcessTarget('demo.with_ui')); + self::assertNull($runtime->getProcessSource('demo.without_ui')); + self::assertNull($runtime->getProcessTarget('demo.without_ui')); + self::assertNull($runtime->getProcessSource('demo.unknown')); + self::assertNull($runtime->getProcessTarget('demo.unknown')); + } + + private function createManager(): ProcessConfigurationsManager + { + $configurations = [ + 'demo.with_ui' => new ProcessConfiguration('demo.with_ui', [], ['ui' => ['source' => 'Pim', 'target' => 'Shop']]), + 'demo.without_ui' => new ProcessConfiguration('demo.without_ui', []), + ]; + $registry = $this->createStub(ProcessConfigurationRegistry::class); + $registry->method('hasProcessConfiguration') + ->willReturnCallback(static fn (string $code): bool => isset($configurations[$code])); + $registry->method('getProcessConfiguration') + ->willReturnCallback(static fn (string $code): ProcessConfiguration => $configurations[$code]); + + return new ProcessConfigurationsManager($registry); + } +} diff --git a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php new file mode 100644 index 0000000..54bd187 --- /dev/null +++ b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php @@ -0,0 +1,53 @@ + ['source' => 'Pim', 'entrypoint_type' => 'file']]); + $registry = $this->createStub(ProcessConfigurationRegistry::class); + $registry->method('hasProcessConfiguration') + ->willReturnCallback(static fn (string $code): bool => 'demo.process' === $code); + $registry->method('getProcessConfiguration')->willReturn($configuration); + + $runtime = new ProcessExtensionRuntime(new ProcessConfigurationsManager($registry)); + + $options = $runtime->getUiOptions('demo.process'); + + // "default" is not compared: its nested resolution depends on the symfony/options-resolver version + self::assertArrayHasKey('default', $options); + unset($options['default']); + self::assertEquals([ + 'source' => 'Pim', + 'target' => null, + 'entrypoint_type' => 'file', + 'ui_launch_mode' => 'modal', + 'constraints' => [], + 'run' => null, + ], $options); + self::assertSame([], $runtime->getUiOptions('demo.unknown')); + } +} diff --git a/tests/Validator/CronExpressionValidatorTest.php b/tests/Validator/CronExpressionValidatorTest.php new file mode 100644 index 0000000..464019d --- /dev/null +++ b/tests/Validator/CronExpressionValidatorTest.php @@ -0,0 +1,85 @@ + + */ +#[CoversClass(CronExpressionValidator::class)] +#[CoversClass(CronExpression::class)] +class CronExpressionValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): CronExpressionValidator + { + return new CronExpressionValidator(); + } + + #[DataProvider('provideValidExpressions')] + public function testValidExpression(string $expression): void + { + $this->validator->validate($expression, new CronExpression()); + + $this->assertNoViolation(); + } + + /** + * @return iterable + */ + public static function provideValidExpressions(): iterable + { + yield 'every minute' => ['* * * * *']; + yield 'every 5 minutes' => ['*/5 * * * *']; + yield 'monday at 08:30' => ['30 8 * * 1']; + yield 'macro' => ['@daily']; + } + + #[DataProvider('provideInvalidExpressions')] + public function testInvalidExpression(string $expression): void + { + $this->validator->validate($expression, new CronExpression()); + + $this->buildViolation('The value "{{ value }}" is not a valid cron expression.') + ->setParameter('{{ value }}', $expression) + ->assertRaised(); + } + + /** + * @return iterable + */ + public static function provideInvalidExpressions(): iterable + { + yield 'garbage' => ['not a cron']; + yield 'too few fields' => ['* * *']; + yield 'out of range' => ['61 * * * *']; + yield 'empty' => ['']; + } + + public function testCustomMessage(): void + { + $constraint = new CronExpression(); + $constraint->message = 'Invalid cron {{ value }}'; + + $this->validator->validate('foo', $constraint); + + $this->buildViolation('Invalid cron {{ value }}') + ->setParameter('{{ value }}', 'foo') + ->assertRaised(); + } +} diff --git a/tests/Validator/EveryExpressionValidatorTest.php b/tests/Validator/EveryExpressionValidatorTest.php new file mode 100644 index 0000000..7ecd2ac --- /dev/null +++ b/tests/Validator/EveryExpressionValidatorTest.php @@ -0,0 +1,70 @@ + + */ +#[CoversClass(EveryExpressionValidator::class)] +#[CoversClass(EveryExpression::class)] +class EveryExpressionValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): EveryExpressionValidator + { + return new EveryExpressionValidator(); + } + + #[DataProvider('provideValidExpressions')] + public function testValidExpression(string $expression): void + { + $this->validator->validate($expression, new EveryExpression()); + + $this->assertNoViolation(); + } + + /** + * @return iterable + */ + public static function provideValidExpressions(): iterable + { + yield 'seconds' => ['10 seconds']; + yield 'day' => ['1 day']; + yield 'relative' => ['+1 hour']; + } + + #[DataProvider('provideInvalidExpressions')] + public function testInvalidExpression(string $expression): void + { + $this->validator->validate($expression, new EveryExpression()); + + $this->buildViolation('The value "{{ value }}" is not every valid expression.') + ->setParameter('{{ value }}', $expression) + ->assertRaised(); + } + + /** + * @return iterable + */ + public static function provideInvalidExpressions(): iterable + { + yield 'garbage' => ['foo']; + yield 'empty' => ['']; + } +} diff --git a/tests/Validator/IsValidProcessCodeValidatorTest.php b/tests/Validator/IsValidProcessCodeValidatorTest.php new file mode 100644 index 0000000..51428c0 --- /dev/null +++ b/tests/Validator/IsValidProcessCodeValidatorTest.php @@ -0,0 +1,69 @@ + + */ +#[CoversClass(IsValidProcessCodeValidator::class)] +#[CoversClass(IsValidProcessCode::class)] +class IsValidProcessCodeValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): IsValidProcessCodeValidator + { + $configurations = [ + 'demo.public' => new ProcessConfiguration('demo.public', []), + 'demo.private' => new ProcessConfiguration('demo.private', [], public: false), + ]; + $registry = $this->createStub(ProcessConfigurationRegistry::class); + $registry->method('hasProcessConfiguration') + ->willReturnCallback(static fn (string $code): bool => isset($configurations[$code])); + $registry->method('getProcessConfiguration') + ->willReturnCallback(static fn (string $code): ProcessConfiguration => $configurations[$code]); + + return new IsValidProcessCodeValidator($registry); + } + + public function testPublicProcess(): void + { + $this->validator->validate('demo.public', new IsValidProcessCode()); + + $this->assertNoViolation(); + } + + public function testPrivateProcess(): void + { + $this->validator->validate('demo.private', new IsValidProcessCode()); + + $this->buildViolation('The process "{{ value }}" is not public.') + ->setParameter('{{ value }}', 'demo.private') + ->assertRaised(); + } + + public function testUnknownProcess(): void + { + $this->validator->validate('demo.unknown', new IsValidProcessCode()); + + $this->buildViolation('The process "{{ value }}" does not exist.') + ->setParameter('{{ value }}', 'demo.unknown') + ->assertRaised(); + } +}