From 2969ff6a5ff92b43ca3592e66483723d30d2b34d Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Thu, 8 Oct 2026 17:06:07 +0200 Subject: [PATCH] #62 Notify the end of the process executions with Symfony Notifier Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + composer.json | 4 + config/services/event_subscriber.yaml | 1 + config/services/notifier.yaml | 13 + docs/cookbooks/notify_process_failures.md | 71 ++++ docs/index.md | 5 +- docs/reference/01-bundle_configuration.md | 17 + docs/reference/02-process_ui_options.md | 3 + docs/reference/09-notifications.md | 137 ++++++++ .../CleverAgeUiProcessExtension.php | 12 + src/DependencyInjection/Configuration.php | 32 ++ src/Event/ProcessExecutionEndedEvent.php | 29 ++ .../ProcessEventSubscriber.php | 13 + src/Manager/ProcessConfigurationsManager.php | 64 +++- src/Notifier/NotificationTrigger.php | 59 ++++ src/Notifier/ProcessExecutionNotification.php | 81 +++++ src/Notifier/ProcessExecutionNotifier.php | 119 +++++++ tests/App/config/packages.yaml | 24 ++ .../CleverAgeUiProcessExtensionTest.php | 21 ++ .../DependencyInjection/ConfigurationTest.php | 32 ++ .../ProcessEventSubscriberTest.php | 39 +++ tests/Functional/HttpProcessExecuteTest.php | 6 + .../ProcessExecutionNotificationTest.php | 92 +++++ .../ProcessConfigurationsManagerTest.php | 68 ++++ tests/Notifier/NotificationTriggerTest.php | 70 ++++ .../ProcessExecutionNotificationTest.php | 112 ++++++ .../Notifier/ProcessExecutionNotifierTest.php | 320 ++++++++++++++++++ 27 files changed, 1440 insertions(+), 5 deletions(-) create mode 100644 config/services/notifier.yaml create mode 100644 docs/cookbooks/notify_process_failures.md create mode 100644 docs/reference/09-notifications.md create mode 100644 src/Event/ProcessExecutionEndedEvent.php create mode 100644 src/Notifier/NotificationTrigger.php create mode 100644 src/Notifier/ProcessExecutionNotification.php create mode 100644 src/Notifier/ProcessExecutionNotifier.php create mode 100644 tests/Functional/ProcessExecutionNotificationTest.php create mode 100644 tests/Notifier/NotificationTriggerTest.php create mode 100644 tests/Notifier/ProcessExecutionNotificationTest.php create mode 100644 tests/Notifier/ProcessExecutionNotifierTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a526ef..42a4e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Latest * [#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. * [#48](https://github.com/cleverage/ui-process-bundle/issues/48) Executions list: the duration is rendered by the `@CleverAgeUiProcess/admin/field/duration.html.twig` template, so that its format can be changed by overriding the template instead of `ProcessExecutionCrudController` (the `$translator` argument of its constructor is no longer used: deprecated, will be removed in 4.0). Add tests. +* [#62](https://github.com/cleverage/ui-process-bundle/issues/62) Notify the end of the process executions with Symfony Notifier (optional `symfony/notifier` dependency): `notification` bundle configuration (`enabled`, default `false`; `statuses` among `failed`, `finish_with_report` (finished with log levels counted in its report), `finish`, default `[failed, finish_with_report]`; `channels`, default the notifier channel policy; `recipients`, default the notifier admin recipients), overridden by the `notification` option of each process. Add the `ProcessExecutionEndedEvent`, dispatched when a top-level process execution has ended and has been saved (new optional `$eventDispatcher` argument of `ProcessEventSubscriber`). Add documentation and a cookbook. Add tests. * [#129](https://github.com/cleverage/ui-process-bundle/issues/129) CI: `migrations` job running the migrations on MySQL, MariaDB and PostgreSQL, with DBAL 3 and 4 (`migrate`, `doctrine:schema:validate`, `migrate first`, `migrate` again); console of the test application (`tests/App/bin/console`), database URL overridable with `DATABASE_URL` (SQLite by default). ## Fixes diff --git a/composer.json b/composer.json index b51399c..2c1a339 100644 --- a/composer.json +++ b/composer.json @@ -92,9 +92,13 @@ "symfony/css-selector": "^6.4 || ^7.4 || ^8", "symfony/debug-bundle": "^6.4 || ^7.4 || ^8", "symfony/maker-bundle": "^1.31", + "symfony/notifier": "^6.4 || ^7.4 || ^8", "symfony/web-profiler-bundle": "^6.4 || ^7.4 || ^8", "vincentlanglet/twig-cs-fixer": "^3.11" }, + "suggest": { + "symfony/notifier": "To notify the end of the process executions" + }, "conflict": { "symfony/twig-bridge": "7.2.0", "twig/twig": "v3.15.0|v3.16.0" diff --git a/config/services/event_subscriber.yaml b/config/services/event_subscriber.yaml index 1a35949..6ba6f92 100644 --- a/config/services/event_subscriber.yaml +++ b/config/services/event_subscriber.yaml @@ -8,3 +8,4 @@ services: - '@cleverage_ui_process.monolog_handler.process' - '@cleverage_ui_process.monolog_handler.doctrine_process' - '@cleverage_ui_process.manager.process_execution' + - '@event_dispatcher' diff --git a/config/services/notifier.yaml b/config/services/notifier.yaml new file mode 100644 index 0000000..e548c82 --- /dev/null +++ b/config/services/notifier.yaml @@ -0,0 +1,13 @@ +services: + # Removed by the extension when symfony/notifier is not installed + cleverage_ui_process.notifier.process_execution: + class: CleverAge\UiProcessBundle\Notifier\ProcessExecutionNotifier + public: false + tags: + - { name: 'kernel.event_subscriber' } + - { name: 'monolog.logger', channel: 'cleverage_ui_process' } + arguments: + $processConfigurationsManager: '@cleverage_ui_process.manager.process_configuration' + $defaultOptions: [] # set by the extension + $notifier: '@?notifier' + $logger: '@?logger' diff --git a/docs/cookbooks/notify_process_failures.md b/docs/cookbooks/notify_process_failures.md new file mode 100644 index 0000000..4e418d4 --- /dev/null +++ b/docs/cookbooks/notify_process_failures.md @@ -0,0 +1,71 @@ +Notify the failures of the processes on Slack and by email +========================================================= + +This recipe sends a Slack message when a process fails or ends with warnings, and an email to the sales team at the +end of every run of their daily import. + +Install the notifier and the Slack bridge: + +```bash +composer require symfony/notifier symfony/slack-notifier +``` + +Configure the notifier: the failures (`high` importance) go to Slack and to the admin recipients by email, the +warnings (`medium` importance) to Slack only: + +```yaml +# config/packages/notifier.yaml +framework: + notifier: + chatter_transports: + slack: '%env(SLACK_DSN)%' # e.g. slack://TOKEN@default?channel=CHANNEL + channel_policy: + high: ['chat/slack', 'email'] + medium: ['chat/slack'] + low: ['chat/slack'] + admin_recipients: + - { email: 'ops@example.com' } +``` + +The `email` channel requires `symfony/mailer` (and its `MAILER_DSN`). + +Enable the notifications for every process, with the default statuses (`failed`, `finish_with_report`): + +```yaml +# config/packages/clever_age_ui_process.yaml +clever_age_ui_process: + notification: + enabled: true +``` + +Then override it for the processes needing it: + +```yaml +# config/packages/process/app.daily_import.yaml +clever_age_process: + configurations: + app.daily_import: + options: + notification: + statuses: [failed, finish_with_report, finish] # every run + channels: ['email'] + recipients: + - { email: 'sales@example.com' } + tasks: + # ... + app.cache_warmup: + options: + notification: + enabled: false + tasks: + # ... +``` + +- A failed process sends a Slack message and an email to `ops@example.com`, e.g. `Process "app.purge_authors" + failed`, with the error, the duration and the log file of the execution. +- A process ending with warnings (`Warning` or higher logs, see `logs.report_increment_level`) sends a Slack message, + e.g. `Process "app.purge_authors" finished with reported logs`, with its report (`Warning: 3, Error: 1`). +- Every run of `app.daily_import` sends an email to `sales@example.com`, `app.cache_warmup` is never notified. + +See [notifications](../reference/09-notifications.md) for the whole configuration and the +`ProcessExecutionEndedEvent` to send your own notifications. diff --git a/docs/index.md b/docs/index.md index 7c10dc7..f8cd1fc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,7 @@ - [Launch a CSV import from a form with file upload](cookbooks/form_file_upload.md) - [Schedule a recurring process](cookbooks/scheduled_process.md) - [Launch a process through the HTTP API](cookbooks/http_api_launch.md) + - [Notify the failures of the processes on Slack and by email](cookbooks/notify_process_failures.md) - Reference - [Bundle configuration](reference/01-bundle_configuration.md) - [Process UI options](reference/02-process_ui_options.md) @@ -16,6 +17,7 @@ - [HTTP API](reference/06-http_api.md) - [Console commands](reference/07-console_commands.md) - [Messenger & asynchronous execution](reference/08-messenger.md) + - [Notifications](reference/09-notifications.md) - [Troubleshooting](troubleshooting.md) - [CleverAge/ProcessBundle documentation](https://github.com/cleverage/process-bundle/blob/main/docs/index.md) @@ -31,7 +33,8 @@ on top of the process bundle. It does not provide any process task. Its features status, duration, report and logs (stored in database and in a log file), - a scheduler to run processes periodically (cron or periodical expressions), - user management (login form, roles, API tokens), -- an HTTP endpoint to launch a process from another application. +- an HTTP endpoint to launch a process from another application, +- notifications of the end of the process executions (failures, warnings...), with Symfony Notifier. It relies on Doctrine ORM (users, executions, logs, schedules), Symfony Messenger (asynchronous execution), Symfony Scheduler and Monolog. diff --git a/docs/reference/01-bundle_configuration.md b/docs/reference/01-bundle_configuration.md index 6a5df63..84f82b4 100644 --- a/docs/reference/01-bundle_configuration.md +++ b/docs/reference/01-bundle_configuration.md @@ -18,6 +18,11 @@ clever_age_ui_process: report_increment_level: Warning design: logo_path: 'bundles/cleverageuiprocess/logo.jpg' + notification: + enabled: false + statuses: [failed, finish_with_report] + channels: [] + recipients: [] ``` Options @@ -49,6 +54,18 @@ Levels are Monolog level names (case-insensitive): `Debug`, `Info`, `Notice`, `W |-------------|----------|-----------------------------------------|------------------------------------------------------------------------------------------------------------| | `logo_path` | `string` | `bundles/cleverageuiprocess/logo.jpg` | Path, relative to the public directory, of the logo displayed in the UI navigation. The default logo requires `bin/console assets:install`. | +### notification + +Notification of the end of the process executions, with [symfony/notifier](https://symfony.com/doc/current/notifier.html) +(optional dependency). Each key can be overridden by process. See [notifications](09-notifications.md). + +| Key | Type | Default | Description | +|--------------|------------|----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | `bool` | `false` | Notify the end of the process executions. `true` requires `symfony/notifier`. | +| `statuses` | `string[]` | `[failed, finish_with_report]` | Ends of process executions to notify: `failed`, `finish_with_report` (finished with log levels counted in its report), `finish` (finished without them). | +| `channels` | `string[]` | `[]` | Notifier channels, e.g. `chat/slack`, `email`. Empty: the `channel_policy` of the notifier, by importance. | +| `recipients` | `array[]` | `[]` | Recipients, with an `email` and/or a `phone`. Empty: the `admin_recipients` of the notifier. | + Container parameters -------------------- diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index 9136a0e..82b766d 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -33,6 +33,9 @@ clever_age_process: Every key is optional: a process without `options.ui` is displayed and launched with a confirmation modal. Unknown keys, or invalid values, raise an options resolver error when the process list is displayed. +The `notification` key, next to `ui`, configures the [notification](09-notifications.md) of the end of the process +executions. + Options ------- diff --git a/docs/reference/09-notifications.md b/docs/reference/09-notifications.md new file mode 100644 index 0000000..bd9da88 --- /dev/null +++ b/docs/reference/09-notifications.md @@ -0,0 +1,137 @@ +Notifications +============= + +The end of a process execution can be notified (Slack, Teams, email, SMS...) with +[symfony/notifier](https://symfony.com/doc/current/notifier.html), whatever the way the process was launched (UI, +console, HTTP API, scheduler). Only the top-level process is notified, not its sub-processes. + +Installation +------------ + +`symfony/notifier` is an optional dependency of the bundle: + +```bash +composer require symfony/notifier +``` + +Then configure the notifier itself (transports, channel policy, admin recipients), see the +[Symfony documentation](https://symfony.com/doc/current/notifier.html), e.g.: + +```yaml +# config/packages/notifier.yaml +framework: + notifier: + chatter_transports: + slack: '%env(SLACK_DSN)%' + channel_policy: + high: ['chat/slack', 'email'] + medium: ['chat/slack'] + low: ['chat/slack'] + admin_recipients: + - { email: 'ops@example.com' } +``` + +Enabling `clever_age_ui_process.notification.enabled` without `symfony/notifier` installed raises an error when +the container is built. If `symfony/notifier` is installed but the notifier is not enabled (`framework.notifier`), +nothing is sent and a warning is logged. + +Configuration +------------- + +The notifications are disabled by default. They are configured for every process in the +[bundle configuration](01-bundle_configuration.md#notification), and each key can be overridden by process, under the +`notification` key of its options (next to the [`ui` options](02-process_ui_options.md)): + +```yaml +# config/packages/clever_age_ui_process.yaml +clever_age_ui_process: + notification: + enabled: true + statuses: [failed, finish_with_report] # default + +# config/packages/process/app.daily_import.yaml +clever_age_process: + configurations: + app.daily_import: + options: + notification: + statuses: [failed, finish_with_report, finish] # also notify the successful imports + channels: ['email'] + recipients: + - { email: 'sales@example.com' } + tasks: + # ... + app.cache_warmup: + options: + notification: + enabled: false # never notified + tasks: + # ... +``` + +| Key | Type | Description | +|--------------|------------|------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | `bool` | Notify the end of the executions of this process. | +| `statuses` | `string[]` | Ends of process executions to notify, see [statuses](#statuses). | +| `channels` | `string[]` | Notifier channels, e.g. `chat/slack`, `email`. Empty: the `channel_policy` of the notifier, by importance. | +| `recipients` | `array[]` | Recipients, with an `email` and/or a `phone` (required by the `email` and `sms` channels). Empty: the `admin_recipients` of the notifier. | + +A key missing (or `null`) in the process options is inherited from the bundle configuration. Invalid process options +raise an options resolver error. + +Statuses +-------- + +| Status | End of the process execution | Importance | +|----------------------|------------------------------------------------------------------------------------------------------------------------------------|------------| +| `failed` | Failed (status `failed`). | `high` | +| `finish_with_report` | Finished (status `finish`) with log levels counted in its [report](04-process_executions_and_logs.md#report), e.g. `{"Warning": 3}`. The counted levels depend on `logs.report_increment_level` (default `Warning`). | `medium` | +| `finish` | Finished without log levels counted in its report. | `low` | + +The importance selects the channels of the notifier `channel_policy` when no `channels` are configured. + +Notification +------------ + +The notification (`CleverAge\UiProcessBundle\Notifier\ProcessExecutionNotification`) contains: +- the subject, e.g. `Process "app.daily_import" failed`, +- the status, the start date and the duration of the execution, the log levels counted in its report, the error + message (failed execution), the log file and the id of the process execution, +- the exception of a failed execution (its trace is added to the emails). + +What is displayed depends on the channel and on the transport: the emails contain the subject, the content and the +exception, the chat messages contain the subject, plus the content and the exception for some transports (e.g. +Slack), the SMS contain the subject. + +An error while sending the notification (unknown channel, transport failure...) is logged (`cleverage_ui_process` +Monolog channel) and does not change the result of the process. + +Custom notifications +-------------------- + +The bundle dispatches a `CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent` when a top-level process +execution has ended and has been saved, with the `ProcessExecution` entity (status, dates, report, context...) and +the error of a failed execution. The notification is sent by a listener of this event. To send your own +notifications (or anything else), keep `notification.enabled` to `false` and listen to this event: + +```php +use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; +use CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +#[AsEventListener] +final class ProcessExecutionEndedListener +{ + public function __invoke(ProcessExecutionEndedEvent $event): void + { + if (ProcessExecutionStatus::Failed === $event->processExecution->status) { + // ... + } + } +} +``` + +This event does not require `symfony/notifier`. The events of the process bundle (`cleverage_process.end`, +`cleverage_process.fail`, see the +[process bundle documentation](https://github.com/cleverage/process-bundle/blob/main/docs/04-advanced_workflow.md#events)) +are dispatched for every process, sub-processes included, before the process execution is saved. diff --git a/src/DependencyInjection/CleverAgeUiProcessExtension.php b/src/DependencyInjection/CleverAgeUiProcessExtension.php index 7d6f725..1dbb986 100644 --- a/src/DependencyInjection/CleverAgeUiProcessExtension.php +++ b/src/DependencyInjection/CleverAgeUiProcessExtension.php @@ -19,10 +19,12 @@ use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\Finder\Finder; +use Symfony\Component\Notifier\NotifierInterface; final class CleverAgeUiProcessExtension extends Extension implements PrependExtensionInterface { @@ -51,6 +53,16 @@ public function load(array $configs, ContainerBuilder $container): void $container->getDefinition(ProcessDashboardController::class) ->setArgument('$logoPath', $config['design']['logo_path']); + + if (interface_exists(NotifierInterface::class)) { + $container->getDefinition('cleverage_ui_process.notifier.process_execution') + ->setArgument('$defaultOptions', $config['notification']); + } else { + if ($config['notification']['enabled']) { + throw new LogicException('The notification of the process executions requires symfony/notifier: run "composer require symfony/notifier".'); + } + $container->removeDefinition('cleverage_ui_process.notifier.process_execution'); + } } /** diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 526ada7..a6876cd 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\DependencyInjection; +use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use Monolog\Level; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; @@ -33,6 +34,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addSecuritySection($rootNode); $this->addLogSection($rootNode); $this->addDesignSection($rootNode); + $this->addNotificationSection($rootNode); return $treeBuilder; } @@ -79,4 +81,34 @@ protected function addDesignSection(ArrayNodeDefinition $node): void ->end() ; } + + protected function addNotificationSection(ArrayNodeDefinition $node): void + { + $node + ->children() + ->arrayNode('notification')->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultFalse()->end() // notify the end of the process executions (requires symfony/notifier), can be overridden by process + ->arrayNode('statuses') // ends of process executions to notify + ->defaultValue([NotificationTrigger::Failed->value, NotificationTrigger::FinishWithReport->value]) + ->enumPrototype()->values(NotificationTrigger::values())->end() + ->end() + ->arrayNode('channels')->scalarPrototype()->end()->end() // notifier channels (e.g. "chat/slack", "email"), the channel policy of the notifier if empty + ->arrayNode('recipients') // the admin recipients of the notifier if empty + ->arrayPrototype() + ->children() + ->scalarNode('email')->defaultNull()->end() + ->scalarNode('phone')->defaultNull()->end() + ->end() + ->validate() + ->ifTrue(static fn (array $recipient): bool => null === $recipient['email'] && null === $recipient['phone']) + ->thenInvalid('A notification recipient must have an "email" or a "phone".') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } } diff --git a/src/Event/ProcessExecutionEndedEvent.php b/src/Event/ProcessExecutionEndedEvent.php new file mode 100644 index 0000000..c810092 --- /dev/null +++ b/src/Event/ProcessExecutionEndedEvent.php @@ -0,0 +1,29 @@ +processExecutionManager->getCurrentProcessExecution()->end(); $this->processExecutionManager->save()->unsetProcessExecution($event->getProcessCode()); $this->processHandler->close(); + $this->dispatchEnded($event); } } @@ -63,6 +67,15 @@ public function fail(ProcessEvent $event): void $this->processExecutionManager->getCurrentProcessExecution()->end(); $this->processExecutionManager->save()->unsetProcessExecution($event->getProcessCode()); $this->processHandler->close(); + $this->dispatchEnded($event); + } + } + + private function dispatchEnded(ProcessEvent $event): void + { + $processExecution = $this->processExecutionManager->getLastProcessExecution(); + if ($processExecution instanceof ProcessExecution) { + $this->eventDispatcher?->dispatch(new ProcessExecutionEndedEvent($processExecution, $event->getProcessError())); } } diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index fa97237..fa8bfce 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -16,6 +16,8 @@ use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\ProcessBundle\Validator\ConstraintLoader; +use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraint; @@ -30,6 +32,12 @@ * 'run': ?bool, * 'default': array{'input': mixed, 'context': array} * } + * @phpstan-type NotificationOptions array{ + * 'enabled': ?bool, + * 'statuses': ?string[], + * 'channels': ?list, + * 'recipients': ?array + * } */ final readonly class ProcessConfigurationsManager { @@ -60,17 +68,65 @@ public function getUiOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveUiOptions($configuration->getOptions())['ui']; + return $this->resolveOptions($configuration->getOptions())['ui']; + } + + /** + * The "notification" option of the process, null values are inherited from the bundle configuration. + * + * @return NotificationOptions|null + */ + public function getNotificationOptions(string $processCode): ?array + { + if (false === $this->registry->hasProcessConfiguration($processCode)) { + return null; + } + + $configuration = $this->registry->getProcessConfiguration($processCode); + + return $this->resolveOptions($configuration->getOptions())['notification']; } /** * @param array $options * - * @return array{'ui': UiOptions} + * @return array{'ui': UiOptions, 'notification': NotificationOptions} */ - private function resolveUiOptions(array $options): array + private function resolveOptions(array $options): array { $resolver = new OptionsResolver(); + $resolver->setDefault('notification', []); + $resolver->setAllowedTypes('notification', 'array'); + $resolver->setNormalizer('notification', static function (Options $options, array $notification): array { + $notificationResolver = new OptionsResolver(); + $notificationResolver->setDefaults(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null]); + $notificationResolver->setAllowedTypes('enabled', ['null', 'bool']); + $notificationResolver->setAllowedTypes('statuses', ['null', 'string[]']); + $notificationResolver->setAllowedValues('statuses', static fn (?array $statuses): bool => null === $statuses || [] === array_diff($statuses, NotificationTrigger::values())); + $notificationResolver->setAllowedTypes('channels', ['null', 'string[]']); + $notificationResolver->setAllowedTypes('recipients', ['null', 'array[]']); + $notificationResolver->setNormalizer('recipients', static function (Options $options, ?array $recipients): ?array { + if (null === $recipients) { + return null; + } + $recipientResolver = new OptionsResolver(); + $recipientResolver->setDefaults(['email' => null, 'phone' => null]); + $recipientResolver->setAllowedTypes('email', ['null', 'string']); + $recipientResolver->setAllowedTypes('phone', ['null', 'string']); + + $recipientResolver->setNormalizer('phone', static function (Options $options, ?string $phone): ?string { + if (null === $options['email'] && null === $phone) { + throw new InvalidOptionsException('A notification recipient must have an "email" or a "phone".'); + } + + return $phone; + }); + + return array_values(array_map($recipientResolver->resolve(...), $recipients)); + }); + + return $notificationResolver->resolve($notification); + }); $resolver->setDefault('ui', []); $resolver->setAllowedTypes('ui', 'array'); $resolver->setNormalizer('ui', static function (Options $options, array $ui): array { @@ -109,7 +165,7 @@ private function resolveUiOptions(array $options): array return $uiResolver->resolve($ui); }); /** - * @var array{'ui': UiOptions} $options + * @var array{'ui': UiOptions, 'notification': NotificationOptions} $options */ $options = $resolver->resolve($options); diff --git a/src/Notifier/NotificationTrigger.php b/src/Notifier/NotificationTrigger.php new file mode 100644 index 0000000..bb73bc0 --- /dev/null +++ b/src/Notifier/NotificationTrigger.php @@ -0,0 +1,59 @@ +status) { + ProcessExecutionStatus::Failed => self::Failed, + ProcessExecutionStatus::Finish => [] === self::getReportedLevels($processExecution) ? self::Finish : self::FinishWithReport, + ProcessExecutionStatus::Started => null, + }; + } + + /** + * The log levels counted in the report of the process execution, other report keys are left out. + * + * @return array + */ + public static function getReportedLevels(ProcessExecution $processExecution): array + { + /** @var array $report */ + $report = $processExecution->getReport(); + + return array_intersect_key($report, array_flip(array_map(static fn (Level $level): string => $level->name, Level::cases()))); + } + + /** + * @return string[] + */ + public static function values(): array + { + return array_map(static fn (self $trigger): string => $trigger->value, self::cases()); + } +} diff --git a/src/Notifier/ProcessExecutionNotification.php b/src/Notifier/ProcessExecutionNotification.php new file mode 100644 index 0000000..fd6e4f7 --- /dev/null +++ b/src/Notifier/ProcessExecutionNotification.php @@ -0,0 +1,81 @@ + $channels empty to use the channel policy of the notifier (by importance) + */ + public function __construct( + public readonly ProcessExecution $processExecution, + public readonly NotificationTrigger $trigger, + ?\Throwable $error = null, + array $channels = [], + ) { + parent::__construct('', $channels); + if ($error instanceof \Throwable) { + // Before the subject: the exception replaces it + $this->exception($error); + } + $this->subject($this->buildSubject()); + $this->content($this->buildContent($error)); + $this->importance(match ($trigger) { + NotificationTrigger::Failed => self::IMPORTANCE_HIGH, + NotificationTrigger::FinishWithReport => self::IMPORTANCE_MEDIUM, + NotificationTrigger::Finish => self::IMPORTANCE_LOW, + }); + } + + protected function buildSubject(): string + { + return \sprintf('Process "%s" %s', $this->processExecution->code, match ($this->trigger) { + NotificationTrigger::Failed => 'failed', + NotificationTrigger::FinishWithReport => 'finished with reported logs', + NotificationTrigger::Finish => 'finished', + }); + } + + protected function buildContent(?\Throwable $error): string + { + $lines = [ + 'Status: '.$this->processExecution->status->value, + 'Start date: '.$this->processExecution->startDate->format(\DateTimeInterface::ATOM), + 'Duration: '.($this->processExecution->duration() ?? '-'), + ]; + $reportedLevels = NotificationTrigger::getReportedLevels($this->processExecution); + if ([] !== $reportedLevels) { + $lines[] = 'Report: '.implode(', ', array_map( + static fn (string $level, mixed $count): string => \sprintf('%s: %s', $level, \is_scalar($count) ? $count : get_debug_type($count)), + array_keys($reportedLevels), + $reportedLevels + )); + } + if ($error instanceof \Throwable) { + $lines[] = 'Error: '.$error->getMessage(); + } + $lines[] = 'Log file: '.$this->processExecution->code.'/'.$this->processExecution->logFilename; + if (null !== $this->processExecution->getId()) { + $lines[] = 'Process execution: '.$this->processExecution->getId(); + } + + return implode("\n", $lines); + } +} diff --git a/src/Notifier/ProcessExecutionNotifier.php b/src/Notifier/ProcessExecutionNotifier.php new file mode 100644 index 0000000..53283a0 --- /dev/null +++ b/src/Notifier/ProcessExecutionNotifier.php @@ -0,0 +1,119 @@ +, + * 'recipients': array + * } + */ +final readonly class ProcessExecutionNotifier implements EventSubscriberInterface +{ + /** + * @param ResolvedNotificationOptions $defaultOptions + */ + public function __construct( + private ProcessConfigurationsManager $processConfigurationsManager, + private array $defaultOptions, + private ?NotifierInterface $notifier = null, + private ?LoggerInterface $logger = null, + ) { + } + + public function onProcessExecutionEnded(ProcessExecutionEndedEvent $event): void + { + $processExecution = $event->processExecution; + $options = $this->getOptions($processExecution->code); + if (!$options['enabled']) { + return; + } + $trigger = NotificationTrigger::fromProcessExecution($processExecution); + if (!$trigger instanceof NotificationTrigger || !\in_array($trigger->value, $options['statuses'], true)) { + return; + } + if (!$this->notifier instanceof NotifierInterface) { + $this->logger?->warning('The notification of the process execution is not sent: the notifier is not enabled (framework.notifier).', ['process' => $processExecution->code]); + + return; + } + + try { + $this->notifier->send( + new ProcessExecutionNotification($processExecution, $trigger, $event->error, $options['channels']), + ...$this->getRecipients($options['recipients']) + ); + } catch (\Throwable $exception) { + // A notification failure must not change the result of the process + $this->logger?->error('Unable to send the notification of the process execution: {error}', [ + 'process' => $processExecution->code, + 'error' => $exception->getMessage(), + 'exception' => $exception, + ]); + } + } + + /** + * @return ResolvedNotificationOptions + */ + public function getOptions(string $processCode): array + { + $processOptions = $this->processConfigurationsManager->getNotificationOptions($processCode) ?? []; + + return [ + 'enabled' => $processOptions['enabled'] ?? $this->defaultOptions['enabled'], + 'statuses' => $processOptions['statuses'] ?? $this->defaultOptions['statuses'], + 'channels' => $processOptions['channels'] ?? $this->defaultOptions['channels'], + 'recipients' => $processOptions['recipients'] ?? $this->defaultOptions['recipients'], + ]; + } + + public static function getSubscribedEvents(): array + { + return [ProcessExecutionEndedEvent::class => 'onProcessExecutionEnded']; + } + + /** + * @param array $recipients + * + * @return RecipientInterface[] the configured recipients, the admin recipients of the notifier by default + */ + private function getRecipients(array $recipients): array + { + if ([] === $recipients) { + return $this->notifier instanceof Notifier ? $this->notifier->getAdminRecipients() : []; + } + + return array_map( + static fn (array $recipient): Recipient => new Recipient($recipient['email'] ?? '', $recipient['phone'] ?? ''), + $recipients + ); + } +} diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index 60bd2b8..9c965ed 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -17,6 +17,13 @@ framework: scheduler_cron: 'in-memory://' validation: email_validation_mode: html5 + notifier: + chatter_transports: + test: 'null://null' + channel_policy: + high: ['chat/test'] + medium: ['chat/test'] + low: ['chat/test'] security: password_hashers: @@ -106,6 +113,23 @@ clever_age_process: tasks: data: service: '@CleverAge\ProcessBundle\Task\ConstantOutputTask' + test.notification: + public: false + options: + notification: + enabled: true + statuses: [finish] + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.notification_failing: + public: false + options: + notification: + enabled: true + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\ConstantOutputTask' twig_component: anonymous_template_directory: 'components/' diff --git a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php index eaf7d7a..aec8878 100644 --- a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php +++ b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php @@ -19,6 +19,7 @@ use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; +use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; @@ -28,6 +29,7 @@ #[CoversClass(CleverAgeUiProcessExtension::class)] #[UsesClass(Configuration::class)] +#[UsesClass(NotificationTrigger::class)] class CleverAgeUiProcessExtensionTest extends TestCase { public function testLoadWithDefaultConfiguration(): void @@ -56,6 +58,25 @@ public function testLoadWithDefaultConfiguration(): void 'bundles/cleverageuiprocess/logo.jpg', $container->getDefinition(ProcessDashboardController::class)->getArgument('$logoPath') ); + + // symfony/notifier is installed (dev dependency) + self::assertSame( + ['enabled' => false, 'statuses' => ['failed', 'finish_with_report'], 'channels' => [], 'recipients' => []], + $container->getDefinition('cleverage_ui_process.notifier.process_execution')->getArgument('$defaultOptions') + ); + } + + public function testLoadWithNotificationConfiguration(): void + { + $container = $this->createContainer('prod'); + (new CleverAgeUiProcessExtension())->load([ + ['notification' => ['enabled' => true, 'statuses' => ['finish'], 'channels' => ['chat/slack']]], + ], $container); + + self::assertSame( + ['enabled' => true, 'statuses' => ['finish'], 'channels' => ['chat/slack'], 'recipients' => []], + $container->getDefinition('cleverage_ui_process.notifier.process_execution')->getArgument('$defaultOptions') + ); } public function testLoadWithDefaultConfigurationInDevEnvironment(): void diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index c092ab9..6038b6c 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -14,13 +14,16 @@ namespace CleverAge\UiProcessBundle\Tests\DependencyInjection; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; +use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; #[CoversClass(Configuration::class)] +#[UsesClass(NotificationTrigger::class)] class ConfigurationTest extends TestCase { /** @@ -50,6 +53,12 @@ public function testDefaultConfiguration(string $env, string $expectedLevel): vo 'design' => [ 'logo_path' => 'bundles/cleverageuiprocess/logo.jpg', ], + 'notification' => [ + 'enabled' => false, + 'statuses' => ['failed', 'finish_with_report'], + 'channels' => [], + 'recipients' => [], + ], ], $this->process($env, []) ); @@ -79,6 +88,15 @@ public function testCustomConfiguration(): void 'design' => [ 'logo_path' => 'images/my-logo.png', ], + 'notification' => [ + 'enabled' => true, + 'statuses' => ['failed', 'finish'], + 'channels' => ['chat/slack', 'email'], + 'recipients' => [ + ['email' => 'ops@example.com', 'phone' => null], + ['phone' => '+33600000000', 'email' => null], + ], + ], ], $this->process('dev', [ 'security' => ['roles' => ['ROLE_ADMIN', 'ROLE_OPERATOR']], @@ -89,6 +107,12 @@ public function testCustomConfiguration(): void 'report_increment_level' => 'Critical', ], 'design' => ['logo_path' => 'images/my-logo.png'], + 'notification' => [ + 'enabled' => true, + 'statuses' => ['failed', 'finish'], + 'channels' => ['chat/slack', 'email'], + 'recipients' => [['email' => 'ops@example.com'], ['phone' => '+33600000000']], + ], ]) ); } @@ -135,6 +159,14 @@ public static function provideInvalidConfigurations(): iterable 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']; + yield 'unknown notification status' => [ + ['notification' => ['statuses' => ['started']]], + 'clever_age_ui_process.notification.statuses', + ]; + yield 'notification recipient without email nor phone' => [ + ['notification' => ['recipients' => [[]]]], + 'A notification recipient must have an "email" or a "phone".', + ]; } /** diff --git a/tests/EventSubscriber/ProcessEventSubscriberTest.php b/tests/EventSubscriber/ProcessEventSubscriberTest.php index 3dd1226..cefff82 100644 --- a/tests/EventSubscriber/ProcessEventSubscriberTest.php +++ b/tests/EventSubscriber/ProcessEventSubscriberTest.php @@ -17,6 +17,7 @@ use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; +use CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; @@ -29,10 +30,12 @@ use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; #[CoversClass(ProcessEventSubscriber::class)] #[UsesClass(DoctrineProcessHandler::class)] #[UsesClass(LogRecord::class)] +#[UsesClass(ProcessExecutionEndedEvent::class)] #[UsesClass(ProcessExecution::class)] #[UsesClass(ProcessExecutionManager::class)] #[UsesClass(ProcessHandler::class)] @@ -159,6 +162,42 @@ public function testSubProcessEndIsIgnored(string $method): void self::assertTrue($processHandler->hasFilename()); } + /** + * @param 'success'|'fail' $method + */ + #[DataProvider('provideEnds')] + public function testProcessEndDispatchesTheProcessExecutionEndedEvent(string $method, ProcessExecutionStatus $expectedStatus): void + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + $processExecutionManager = new ProcessExecutionManager($this->createRepositoryStub()); + $processExecutionManager->setCurrentProcessExecution($processExecution); + $eventDispatcher = new EventDispatcher(); + $events = []; + $eventDispatcher->addListener(ProcessExecutionEndedEvent::class, static function (ProcessExecutionEndedEvent $event) use (&$events): void { + // The process execution has ended and is saved when the event is dispatched + self::assertNotNull($event->processExecution->endDate); + $events[] = $event; + }); + $error = 'fail' === $method ? new \RuntimeException('Process error') : null; + $doctrineProcessHandler = new DoctrineProcessHandler(); + $doctrineProcessHandler->disable(); + $subscriber = new ProcessEventSubscriber( + new ProcessHandler('/var/log/process', $processExecutionManager), + $doctrineProcessHandler, + $processExecutionManager, + $eventDispatcher + ); + + $subscriber->{$method}(new ProcessEvent('test.process', null, [], null, $error)); + // Sub-process end: no event + $subscriber->{$method}(new ProcessEvent('sub.process')); + + self::assertCount(1, $events); + self::assertSame($processExecution, $events[0]->processExecution); + self::assertSame($expectedStatus, $events[0]->processExecution->status); + self::assertSame($error, $events[0]->error); + } + public function testFlushDoctrineLogs(): void { $processExecutionManager = new ProcessExecutionManager($this->createRepositoryStub()); diff --git a/tests/Functional/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php index 32c04f0..2f1d83a 100644 --- a/tests/Functional/HttpProcessExecuteTest.php +++ b/tests/Functional/HttpProcessExecuteTest.php @@ -24,6 +24,7 @@ use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Entity\User; +use CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Http\Model\HttpProcessExecution; use CleverAge\UiProcessBundle\Http\ValueResolver\HttpProcessExecuteValueResolver; @@ -32,6 +33,8 @@ use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; +use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; +use CleverAge\UiProcessBundle\Notifier\ProcessExecutionNotifier; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; use CleverAge\UiProcessBundle\Security\HttpProcessExecutionAuthenticator; use CleverAge\UiProcessBundle\Twig\Extension\LogLevelExtension; @@ -72,6 +75,9 @@ #[UsesClass(ProcessExecutionExtension::class)] #[UsesClass(ProcessExtension::class)] #[UsesClass(IsValidProcessCodeValidator::class)] +#[UsesClass(ProcessExecutionEndedEvent::class)] +#[UsesClass(NotificationTrigger::class)] +#[UsesClass(ProcessExecutionNotifier::class)] class HttpProcessExecuteTest extends FunctionalTestCase { private const TOKEN = 'api-token'; diff --git a/tests/Functional/ProcessExecutionNotificationTest.php b/tests/Functional/ProcessExecutionNotificationTest.php new file mode 100644 index 0000000..474d441 --- /dev/null +++ b/tests/Functional/ProcessExecutionNotificationTest.php @@ -0,0 +1,92 @@ +getProcessManager()->execute('test.notification'); + + self::assertNotificationCount(1); + self::assertStringContainsString('Process "test.notification" finished', (string) self::getNotifierMessage()?->getSubject()); + self::assertSame('test', self::getNotifierMessage()?->getTransport()); + } + + public function testFailedProcessNotified(): void + { + try { + $this->getProcessManager()->execute('test.notification_failing'); + self::fail('The process should have failed'); + } catch (\Throwable) { + } + + self::assertNotificationCount(1); + self::assertStringContainsString('Process "test.notification_failing" failed', (string) self::getNotifierMessage()?->getSubject()); + } + + public function testNotificationDisabledByDefault(): void + { + $this->getProcessManager()->execute('test.process'); + try { + $this->getProcessManager()->execute('test.failing'); + } catch (\Throwable) { + } + + self::assertNotificationCount(0); + } + + private function getProcessManager(): ProcessManager + { + /** @var ProcessManager $processManager */ + $processManager = static::getContainer()->get('cleverage_process.manager.process'); + + return $processManager; + } +} diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index 6b7feb3..e8746c1 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -16,8 +16,10 @@ use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; +use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\Component\OptionsResolver\Exception\ExceptionInterface as OptionsResolverException; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; @@ -27,6 +29,7 @@ use Symfony\Component\Validator\Constraints\NotBlank; #[CoversClass(ProcessConfigurationsManager::class)] +#[UsesClass(NotificationTrigger::class)] class ProcessConfigurationsManagerTest extends TestCase { public function testPublicAndPrivateProcesses(): void @@ -166,6 +169,71 @@ public function testInvalidLaunchMode(): void $manager->getUiOptions('test.process'); } + public function testNotificationOptionsOfAnUnknownProcess(): void + { + self::assertNull($this->createManager(['test.process' => $this->rawProcess()])->getNotificationOptions('unknown')); + } + + public function testDefaultNotificationOptions(): void + { + self::assertSame( + ['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null], + $this->createManager(['test.process' => $this->rawProcess()])->getNotificationOptions('test.process') + ); + } + + public function testConfiguredNotificationOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess([ + 'ui' => ['source' => 'ERP'], + 'notification' => [ + 'enabled' => true, + 'statuses' => ['finish'], + 'channels' => ['chat/slack'], + 'recipients' => [['email' => 'ops@example.com'], ['phone' => '+33600000000']], + ], + ])]); + + self::assertSame( + [ + 'enabled' => true, + 'statuses' => ['finish'], + 'channels' => ['chat/slack'], + 'recipients' => [['email' => 'ops@example.com', 'phone' => null], ['email' => null, 'phone' => '+33600000000']], + ], + $manager->getNotificationOptions('test.process') + ); + // The "notification" option is accepted next to the "ui" one + self::assertSame('ERP', $manager->getUiOptions('test.process')['source'] ?? null); + } + + /** + * @return iterable, class-string<\Throwable>}> + */ + public static function provideInvalidNotificationOptions(): iterable + { + yield 'not an array' => [['notification' => true], InvalidOptionsException::class]; + yield 'unknown key' => [['notification' => ['level' => 'Error']], UndefinedOptionsException::class]; + yield 'enabled not a boolean' => [['notification' => ['enabled' => 'yes']], InvalidOptionsException::class]; + yield 'unknown status' => [['notification' => ['statuses' => ['started']]], InvalidOptionsException::class]; + yield 'channels not an array' => [['notification' => ['channels' => 'email']], InvalidOptionsException::class]; + yield 'recipient without email nor phone' => [['notification' => ['recipients' => [[]]]], InvalidOptionsException::class]; + yield 'recipient unknown key' => [['notification' => ['recipients' => [['name' => 'Ops']]]], UndefinedOptionsException::class]; + } + + /** + * @param array $options + * @param class-string<\Throwable> $exception + */ + #[DataProvider('provideInvalidNotificationOptions')] + public function testInvalidNotificationOptions(array $options, string $exception): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess($options)]); + + $this->expectException($exception); + $manager->getNotificationOptions('test.process'); + } + /** * @param array> $rawConfiguration */ diff --git a/tests/Notifier/NotificationTriggerTest.php b/tests/Notifier/NotificationTriggerTest.php new file mode 100644 index 0000000..28e711a --- /dev/null +++ b/tests/Notifier/NotificationTriggerTest.php @@ -0,0 +1,70 @@ +, ?NotificationTrigger}> + */ + public static function provideProcessExecutions(): iterable + { + yield 'started' => [ProcessExecutionStatus::Started, [], null]; + yield 'failed' => [ProcessExecutionStatus::Failed, [], NotificationTrigger::Failed]; + yield 'failed with report' => [ProcessExecutionStatus::Failed, ['Error' => 2], NotificationTrigger::Failed]; + yield 'finish' => [ProcessExecutionStatus::Finish, [], NotificationTrigger::Finish]; + yield 'finish with a custom report key' => [ProcessExecutionStatus::Finish, ['imported' => 12], NotificationTrigger::Finish]; + yield 'finish with report' => [ProcessExecutionStatus::Finish, ['Warning' => 3, 'imported' => 12], NotificationTrigger::FinishWithReport]; + } + + /** + * @param array $report + */ + #[DataProvider('provideProcessExecutions')] + public function testFromProcessExecution(ProcessExecutionStatus $status, array $report, ?NotificationTrigger $expected): void + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + $processExecution->setStatus($status); + foreach ($report as $key => $value) { + $processExecution->addReport($key, $value); + } + + self::assertSame($expected, NotificationTrigger::fromProcessExecution($processExecution)); + } + + public function testGetReportedLevels(): void + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + $processExecution->addReport('Warning', 3); + $processExecution->addReport('imported', 12); + $processExecution->addReport('Critical', 1); + + self::assertSame(['Warning' => 3, 'Critical' => 1], NotificationTrigger::getReportedLevels($processExecution)); + } + + public function testValues(): void + { + self::assertSame(['failed', 'finish_with_report', 'finish'], NotificationTrigger::values()); + } +} diff --git a/tests/Notifier/ProcessExecutionNotificationTest.php b/tests/Notifier/ProcessExecutionNotificationTest.php new file mode 100644 index 0000000..f162c62 --- /dev/null +++ b/tests/Notifier/ProcessExecutionNotificationTest.php @@ -0,0 +1,112 @@ +createProcessExecution(ProcessExecutionStatus::Failed, ['Error' => 1]); + + $notification = new ProcessExecutionNotification( + $processExecution, + NotificationTrigger::Failed, + new \RuntimeException('Something went wrong'), + ['chat/slack'] + ); + + self::assertSame($processExecution, $notification->processExecution); + self::assertSame(NotificationTrigger::Failed, $notification->trigger); + self::assertSame('Process "test.process" failed', $notification->getSubject()); + self::assertSame(Notification::IMPORTANCE_HIGH, $notification->getImportance()); + self::assertSame(['chat/slack'], $notification->getChannels(new NoRecipient())); + self::assertSame('Something went wrong', $notification->getException()?->getMessage()); + self::assertSame( + implode("\n", [ + 'Status: failed', + 'Start date: '.$processExecution->startDate->format(\DateTimeInterface::ATOM), + 'Duration: 00 hour(s) 00 min(s) 00 s', + 'Report: Error: 1', + 'Error: Something went wrong', + 'Log file: test.process/test.log', + ]), + $notification->getContent() + ); + } + + public function testFinishWithReport(): void + { + $processExecution = $this->createProcessExecution(ProcessExecutionStatus::Finish, ['Warning' => 3, 'Error' => 1, 'imported' => 12]); + + $notification = new ProcessExecutionNotification($processExecution, NotificationTrigger::FinishWithReport); + + self::assertSame('Process "test.process" finished with reported logs', $notification->getSubject()); + self::assertSame(Notification::IMPORTANCE_MEDIUM, $notification->getImportance()); + self::assertSame([], $notification->getChannels(new NoRecipient())); + self::assertNull($notification->getException()); + self::assertStringContainsString("\nReport: Warning: 3, Error: 1\n", $notification->getContent()); + self::assertStringNotContainsString('Error: ', str_replace('Error: 1', '', $notification->getContent())); + } + + public function testFinish(): void + { + $processExecution = $this->createProcessExecution(ProcessExecutionStatus::Finish, []); + $reflection = new \ReflectionProperty(ProcessExecution::class, 'id'); + $reflection->setValue($processExecution, 42); + + $notification = new ProcessExecutionNotification($processExecution, NotificationTrigger::Finish); + + self::assertSame('Process "test.process" finished', $notification->getSubject()); + self::assertSame(Notification::IMPORTANCE_LOW, $notification->getImportance()); + self::assertStringNotContainsString('Report:', $notification->getContent()); + self::assertStringEndsWith("\nLog file: test.process/test.log\nProcess execution: 42", $notification->getContent()); + } + + public function testRunningProcessExecution(): void + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + + $notification = new ProcessExecutionNotification($processExecution, NotificationTrigger::Finish); + + self::assertStringContainsString("\nDuration: -\n", $notification->getContent()); + } + + /** + * @param array $report + */ + private function createProcessExecution(ProcessExecutionStatus $status, array $report): ProcessExecution + { + $processExecution = new ProcessExecution('test.process', 'test.log'); + $processExecution->setStatus($status); + foreach ($report as $key => $value) { + $processExecution->addReport($key, $value); + } + $processExecution->end(); + + return $processExecution; + } +} diff --git a/tests/Notifier/ProcessExecutionNotifierTest.php b/tests/Notifier/ProcessExecutionNotifierTest.php new file mode 100644 index 0000000..e1d24e2 --- /dev/null +++ b/tests/Notifier/ProcessExecutionNotifierTest.php @@ -0,0 +1,320 @@ + true, + 'statuses' => ['failed', 'finish_with_report'], + 'channels' => [], + 'recipients' => [], + ]; + + /** @var \ArrayObject */ + private \ArrayObject $sent; + + /** @var \ArrayObject}> */ + private \ArrayObject $logs; + + protected function setUp(): void + { + $this->sent = new \ArrayObject(); + $this->logs = new \ArrayObject(); + } + + public function testSubscribedEvents(): void + { + self::assertSame( + [ProcessExecutionEndedEvent::class => 'onProcessExecutionEnded'], + ProcessExecutionNotifier::getSubscribedEvents() + ); + } + + /** + * @return iterable, bool}> + */ + public static function provideDefaultStatuses(): iterable + { + yield 'failed' => [ProcessExecutionStatus::Failed, [], true]; + yield 'finish with report' => [ProcessExecutionStatus::Finish, ['Warning' => 1], true]; + yield 'finish' => [ProcessExecutionStatus::Finish, [], false]; + yield 'started' => [ProcessExecutionStatus::Started, [], false]; + } + + /** + * @param array $report + */ + #[DataProvider('provideDefaultStatuses')] + public function testDefaultStatuses(ProcessExecutionStatus $status, array $report, bool $expectedSent): void + { + $processExecution = $this->createProcessExecution('test.process', $status, $report); + + $this->createNotifier()->onProcessExecutionEnded(new ProcessExecutionEndedEvent($processExecution)); + + self::assertCount($expectedSent ? 1 : 0, $this->sent); + } + + public function testNotificationIsSentToTheAdminRecipientsWithTheChannelPolicy(): void + { + $error = new \RuntimeException('Process error'); + $processExecution = $this->createProcessExecution('test.process', ProcessExecutionStatus::Failed); + $admin = new Recipient('admin@example.com'); + + $this->createNotifier(adminRecipients: [$admin])->onProcessExecutionEnded(new ProcessExecutionEndedEvent($processExecution, $error)); + + self::assertCount(1, $this->sent); + [$notification, $recipient, $transport] = $this->getSent()[0]; + self::assertInstanceOf(ProcessExecutionNotification::class, $notification); + self::assertSame($processExecution, $notification->processExecution); + self::assertSame(NotificationTrigger::Failed, $notification->trigger); + self::assertSame('Process error', $notification->getException()?->getMessage()); + self::assertSame($admin, $recipient); + self::assertNull($transport); + } + + public function testNotificationWithoutRecipient(): void + { + $this->createNotifier()->onProcessExecutionEnded( + new ProcessExecutionEndedEvent($this->createProcessExecution('test.process', ProcessExecutionStatus::Failed)) + ); + + self::assertCount(1, $this->sent); + self::assertInstanceOf(NoRecipient::class, $this->getSent()[0][1]); + } + + public function testConfiguredChannelsAndRecipients(): void + { + $this->createNotifier(['channels' => ['test/slack'], 'recipients' => [['email' => 'ops@example.com', 'phone' => null], ['email' => null, 'phone' => '+33600000000']]] + self::DEFAULT_OPTIONS) + ->onProcessExecutionEnded(new ProcessExecutionEndedEvent($this->createProcessExecution('test.process', ProcessExecutionStatus::Failed))); + + self::assertCount(2, $this->sent); + self::assertInstanceOf(Recipient::class, $this->getSent()[0][1]); + self::assertSame('ops@example.com', $this->getSent()[0][1]->getEmail()); + self::assertSame('slack', $this->getSent()[0][2]); + self::assertInstanceOf(Recipient::class, $this->getSent()[1][1]); + self::assertSame('+33600000000', $this->getSent()[1][1]->getPhone()); + } + + public function testDisabled(): void + { + $this->createNotifier(['enabled' => false] + self::DEFAULT_OPTIONS)->onProcessExecutionEnded( + new ProcessExecutionEndedEvent($this->createProcessExecution('test.process', ProcessExecutionStatus::Failed)) + ); + + self::assertCount(0, $this->sent); + } + + public function testProcessOptionsOverrideTheDefaultOptions(): void + { + $notifier = $this->createNotifier(['enabled' => false] + self::DEFAULT_OPTIONS, processOptions: [ + 'test.process' => ['notification' => ['enabled' => true, 'statuses' => ['finish'], 'channels' => ['test/teams']]], + 'test.disabled' => ['notification' => ['enabled' => false]], + ]); + + self::assertSame( + ['enabled' => true, 'statuses' => ['finish'], 'channels' => ['test/teams'], 'recipients' => []], + $notifier->getOptions('test.process') + ); + self::assertSame(['enabled' => false] + self::DEFAULT_OPTIONS, $notifier->getOptions('test.disabled')); + self::assertSame(['enabled' => false] + self::DEFAULT_OPTIONS, $notifier->getOptions('unknown.process')); + + $notifier->onProcessExecutionEnded(new ProcessExecutionEndedEvent($this->createProcessExecution('test.process', ProcessExecutionStatus::Finish))); + $notifier->onProcessExecutionEnded(new ProcessExecutionEndedEvent($this->createProcessExecution('test.process', ProcessExecutionStatus::Failed))); + $notifier->onProcessExecutionEnded(new ProcessExecutionEndedEvent($this->createProcessExecution('test.disabled', ProcessExecutionStatus::Failed))); + + self::assertCount(1, $this->sent); + self::assertSame(NotificationTrigger::Finish, $this->getNotification(0)->trigger); + self::assertSame('teams', $this->getSent()[0][2]); + } + + public function testWithoutNotifier(): void + { + $notifier = new ProcessExecutionNotifier($this->createConfigurationsManager([]), self::DEFAULT_OPTIONS, null, $this->createLogger()); + + $notifier->onProcessExecutionEnded(new ProcessExecutionEndedEvent($this->createProcessExecution('test.process', ProcessExecutionStatus::Failed))); + + self::assertCount(1, $this->logs); + self::assertSame('warning', $this->getLogs()[0][0]); + self::assertStringContainsString('the notifier is not enabled', (string) $this->getLogs()[0][1]); + } + + public function testSendingErrorIsLogged(): void + { + // Unknown channel: the notifier throws a LogicException + $this->createNotifier(['channels' => ['sms']] + self::DEFAULT_OPTIONS)->onProcessExecutionEnded( + new ProcessExecutionEndedEvent($this->createProcessExecution('test.process', ProcessExecutionStatus::Failed)) + ); + + self::assertCount(0, $this->sent); + self::assertCount(1, $this->logs); + self::assertSame('error', $this->getLogs()[0][0]); + self::assertSame('test.process', $this->getLogs()[0][2]['process']); + self::assertSame('The "sms" channel does not exist.', $this->getLogs()[0][2]['error']); + } + + /** + * @param array{'enabled': bool, 'statuses': string[], 'channels': list, 'recipients': array} $defaultOptions + * @param array> $processOptions + * @param RecipientInterface[] $adminRecipients + */ + private function createNotifier(array $defaultOptions = self::DEFAULT_OPTIONS, array $processOptions = [], array $adminRecipients = []): ProcessExecutionNotifier + { + $channel = new class($this->sent) implements ChannelInterface { + /** + * @param \ArrayObject $sent + */ + public function __construct(private readonly \ArrayObject $sent) + { + } + + public function notify(Notification $notification, RecipientInterface $recipient, ?string $transportName = null): void + { + $this->sent->append([$notification, $recipient, $transportName]); + } + + public function supports(Notification $notification, RecipientInterface $recipient): bool + { + return true; + } + }; + $policy = new ChannelPolicy([ + Notification::IMPORTANCE_HIGH => ['test'], + Notification::IMPORTANCE_MEDIUM => ['test'], + Notification::IMPORTANCE_LOW => ['test'], + ]); + $notifier = new Notifier(['test' => $channel], $policy); + foreach ($adminRecipients as $recipient) { + $notifier->addAdminRecipient($recipient); + } + + return new ProcessExecutionNotifier($this->createConfigurationsManager($processOptions), $defaultOptions, $notifier, $this->createLogger()); + } + + /** + * @param array> $processOptions + */ + private function createConfigurationsManager(array $processOptions): ProcessConfigurationsManager + { + $rawConfiguration = []; + foreach ($processOptions as $code => $options) { + $rawConfiguration[$code] = [ + 'options' => $options, + 'entry_point' => null, + '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, + ], + ], + ]; + } + + return new ProcessConfigurationsManager(new ProcessConfigurationRegistry($rawConfiguration, 'stop')); + } + + private function createLogger(): AbstractLogger + { + return new class($this->logs) extends AbstractLogger { + /** + * @param \ArrayObject}> $logs + */ + public function __construct(private readonly \ArrayObject $logs) + { + } + + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->logs->append([(string) $level, $message, $context]); + } + }; + } + + /** + * @param array $report + */ + private function createProcessExecution(string $code, ProcessExecutionStatus $status, array $report = []): ProcessExecution + { + $processExecution = new ProcessExecution($code, 'test.log'); + $processExecution->setStatus($status); + foreach ($report as $key => $value) { + $processExecution->addReport($key, $value); + } + $processExecution->end(); + + return $processExecution; + } + + /** + * @return array + */ + private function getSent(): array + { + return $this->sent->getArrayCopy(); + } + + /** + * @return array}> + */ + private function getLogs(): array + { + return $this->logs->getArrayCopy(); + } + + private function getNotification(int $index): ProcessExecutionNotification + { + $notification = $this->getSent()[$index][0]; + self::assertInstanceOf(ProcessExecutionNotification::class, $notification); + + return $notification; + } +}