diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index cba0d91..564c300 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -50,6 +50,13 @@ jobs: env: APP_NAME: profile_fields + services: + mailpit: + image: axllent/mailpit:v1.27 + ports: + - 1025:1025 + - 8025:8025 + name: SQLite PHP 8.2 Nextcloud master steps: @@ -71,7 +78,7 @@ jobs: uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: '8.2' - extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib coverage: none ini-file: development ini-values: disable_functions= @@ -85,6 +92,7 @@ jobs: - name: Set up Nextcloud run: | + echo "127.0.0.1 mailpit" | sudo tee -a /etc/hosts mkdir data ./occ maintenance:install \ --verbose \ @@ -93,6 +101,12 @@ jobs: --admin-user admin \ --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} + git clone --depth 1 https://github.com/nextcloud/notifications apps/notifications + composer --working-dir=apps/notifications install --no-dev + ./occ app:enable --force notifications + git clone --depth 1 https://github.com/nextcloud/spreed apps/spreed + composer --working-dir=apps/spreed install --no-dev + ./occ app:enable --force spreed ./occ config:system:set auth.bruteforce.protection.enabled --value false --type boolean ./occ config:system:set ratelimit.protection.enabled --value false --type boolean ./occ config:system:set debug --value true --type boolean diff --git a/README.md b/README.md index 1a2274a..84fd8fe 100644 --- a/README.md +++ b/README.md @@ -39,4 +39,14 @@ Notes: ## Screenshots +### Admin catalog ![Admin catalog](img/screenshots/admin-catalog.png) + +### User management dialog +![User management dialog](img/screenshots/user-management-dialog.png) + +### Personal settings +![Personal settings](img/screenshots/personal-settings.png) + +### Workflow automation +![Workflow automation](img/screenshots/workflow-notify-admins.png) diff --git a/REUSE.toml b/REUSE.toml index db5a88e..1a75cbb 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -26,6 +26,7 @@ path = [ "openapi.json", "package-lock.json", "package.json", + "playwright/fixtures/*.png", "psalm.xml", "src/types/openapi/*.ts", "tests/integration/composer.json", diff --git a/appinfo/info.xml b/appinfo/info.xml index c01cae8..28068aa 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -36,8 +36,9 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform https://github.com/LibreCodeCoop/profile_fields/issues https://github.com/LibreCodeCoop/profile_fields https://raw.githubusercontent.com/LibreCodeCoop/profile_fields/main/img/screenshots/admin-catalog.png - https://raw.githubusercontent.com/LibreCodeCoop/profile_fields/main/img/screenshots/personal-settings.png https://raw.githubusercontent.com/LibreCodeCoop/profile_fields/main/img/screenshots/user-management-dialog.png + https://raw.githubusercontent.com/LibreCodeCoop/profile_fields/main/img/screenshots/personal-settings.png + https://raw.githubusercontent.com/LibreCodeCoop/profile_fields/main/img/screenshots/workflow-notify-admins.png https://github.com/sponsors/LibreSign diff --git a/img/screenshots/admin-catalog-thumb.png b/img/screenshots/admin-catalog-thumb.png index 0452a3b..33c8e9a 100644 Binary files a/img/screenshots/admin-catalog-thumb.png and b/img/screenshots/admin-catalog-thumb.png differ diff --git a/img/screenshots/personal-settings-thumb.png b/img/screenshots/personal-settings-thumb.png index 619c382..6c4c04a 100644 Binary files a/img/screenshots/personal-settings-thumb.png and b/img/screenshots/personal-settings-thumb.png differ diff --git a/img/screenshots/personal-settings.png b/img/screenshots/personal-settings.png index f0ec95f..1434684 100644 Binary files a/img/screenshots/personal-settings.png and b/img/screenshots/personal-settings.png differ diff --git a/img/screenshots/user-management-dialog-thumb.png b/img/screenshots/user-management-dialog-thumb.png index 0f7b3bc..67b2567 100644 Binary files a/img/screenshots/user-management-dialog-thumb.png and b/img/screenshots/user-management-dialog-thumb.png differ diff --git a/img/screenshots/user-management-dialog.png b/img/screenshots/user-management-dialog.png index c71ddaa..218be8c 100644 Binary files a/img/screenshots/user-management-dialog.png and b/img/screenshots/user-management-dialog.png differ diff --git a/img/screenshots/workflow-notify-admins-thumb.png b/img/screenshots/workflow-notify-admins-thumb.png new file mode 100644 index 0000000..e2640b0 Binary files /dev/null and b/img/screenshots/workflow-notify-admins-thumb.png differ diff --git a/img/screenshots/workflow-notify-admins.png b/img/screenshots/workflow-notify-admins.png new file mode 100644 index 0000000..53d1e05 Binary files /dev/null and b/img/screenshots/workflow-notify-admins.png differ diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bafae82..f511b15 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -10,7 +10,12 @@ namespace OCA\ProfileFields\AppInfo; use OCA\ProfileFields\Listener\BeforeTemplateRenderedListener; +use OCA\ProfileFields\Listener\LoadWorkflowSettingsScriptsListener; +use OCA\ProfileFields\Listener\RegisterWorkflowCheckListener; +use OCA\ProfileFields\Listener\RegisterWorkflowEntityListener; +use OCA\ProfileFields\Listener\RegisterWorkflowOperationListener; use OCA\ProfileFields\Listener\UserDeletedCleanupListener; +use OCA\ProfileFields\Notification\Notifier; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -18,6 +23,10 @@ use OCP\IRequest; use OCP\User\Events\UserDeletedEvent; use OCP\Util; +use OCP\WorkflowEngine\Events\LoadSettingsScriptsEvent; +use OCP\WorkflowEngine\Events\RegisterChecksEvent; +use OCP\WorkflowEngine\Events\RegisterEntitiesEvent; +use OCP\WorkflowEngine\Events\RegisterOperationsEvent; /** * @codeCoverageIgnore @@ -32,8 +41,13 @@ public function __construct() { #[\Override] public function register(IRegistrationContext $context): void { + $context->registerNotifierService(Notifier::class); $context->registerEventListener('\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent', BeforeTemplateRenderedListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedCleanupListener::class); + $context->registerEventListener(RegisterEntitiesEvent::class, RegisterWorkflowEntityListener::class); + $context->registerEventListener(RegisterOperationsEvent::class, RegisterWorkflowOperationListener::class); + $context->registerEventListener(RegisterChecksEvent::class, RegisterWorkflowCheckListener::class); + $context->registerEventListener(LoadSettingsScriptsEvent::class, LoadWorkflowSettingsScriptsListener::class); } #[\Override] diff --git a/lib/Command/Developer/Reset.php b/lib/Command/Developer/Reset.php index cca1455..d467a27 100644 --- a/lib/Command/Developer/Reset.php +++ b/lib/Command/Developer/Reset.php @@ -9,6 +9,7 @@ namespace OCA\ProfileFields\Command\Developer; +use OCA\ProfileFields\AppInfo\Application; use OCP\IDBConnection; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -65,12 +66,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($resetDefinitions) { $this->deleteTable('profile_fields_definitions'); + $this->deleteWorkflowTables(); + $this->deleteProfileFieldNotifications(); } $output->writeln('Profile Fields data reset complete.'); return self::SUCCESS; } + private function deleteWorkflowTables(): void { + foreach (['flow_checks', 'flow_operations', 'flow_operations_scope'] as $tableName) { + $this->deleteTable($tableName); + } + } + + private function deleteProfileFieldNotifications(): void { + if (!$this->connection->tableExists('notifications')) { + return; + } + + $query = $this->connection->getQueryBuilder(); + $query->delete('notifications') + ->where($query->expr()->eq('app', $query->createNamedParameter(Application::APP_ID))) + ->executeStatement(); + } + private function deleteTable(string $tableName): void { if (!$this->connection->tableExists($tableName)) { return; diff --git a/lib/Listener/LoadWorkflowSettingsScriptsListener.php b/lib/Listener/LoadWorkflowSettingsScriptsListener.php new file mode 100644 index 0000000..ace7557 --- /dev/null +++ b/lib/Listener/LoadWorkflowSettingsScriptsListener.php @@ -0,0 +1,31 @@ + + */ +class LoadWorkflowSettingsScriptsListener implements IEventListener { + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof LoadSettingsScriptsEvent) { + return; + } + + Util::addStyle(Application::APP_ID, 'profile_fields-workflow'); + Util::addScript(Application::APP_ID, 'profile_fields-workflow', 'workflowengine'); + } +} diff --git a/lib/Listener/RegisterWorkflowCheckListener.php b/lib/Listener/RegisterWorkflowCheckListener.php new file mode 100644 index 0000000..acf141e --- /dev/null +++ b/lib/Listener/RegisterWorkflowCheckListener.php @@ -0,0 +1,34 @@ + + */ +class RegisterWorkflowCheckListener implements IEventListener { + public function __construct( + private UserProfileFieldCheck $check, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof RegisterChecksEvent) { + return; + } + + $event->registerCheck($this->check); + } +} diff --git a/lib/Listener/RegisterWorkflowEntityListener.php b/lib/Listener/RegisterWorkflowEntityListener.php new file mode 100644 index 0000000..1fbbc29 --- /dev/null +++ b/lib/Listener/RegisterWorkflowEntityListener.php @@ -0,0 +1,34 @@ + + */ +class RegisterWorkflowEntityListener implements IEventListener { + public function __construct( + private ProfileFieldValueEntity $entity, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof RegisterEntitiesEvent) { + return; + } + + $event->registerEntity($this->entity); + } +} diff --git a/lib/Listener/RegisterWorkflowOperationListener.php b/lib/Listener/RegisterWorkflowOperationListener.php new file mode 100644 index 0000000..3a60c98 --- /dev/null +++ b/lib/Listener/RegisterWorkflowOperationListener.php @@ -0,0 +1,46 @@ + + */ +class RegisterWorkflowOperationListener implements IEventListener { + public function __construct( + private LogProfileFieldChangeOperation $operation, + private EmailUserProfileFieldChangeOperation $emailUserOperation, + private NotifyAdminsOrGroupsProfileFieldChangeOperation $notifyAdminsOrGroupsOperation, + private CreateTalkConversationProfileFieldChangeOperation $createTalkConversationOperation, + private SendWebhookProfileFieldChangeOperation $sendWebhookOperation, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof RegisterOperationsEvent) { + return; + } + + $event->registerOperation($this->operation); + $event->registerOperation($this->emailUserOperation); + $event->registerOperation($this->notifyAdminsOrGroupsOperation); + $event->registerOperation($this->createTalkConversationOperation); + $event->registerOperation($this->sendWebhookOperation); + } +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php new file mode 100644 index 0000000..b208a43 --- /dev/null +++ b/lib/Notification/Notifier.php @@ -0,0 +1,65 @@ +factory->get(Application::APP_ID)->t('Profile fields'); + } + + #[\Override] + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== Application::APP_ID) { + throw new UnknownNotificationException(); + } + + $l10n = $this->factory->get(Application::APP_ID, $languageCode); + + if ($notification->getSubject() === 'profile_field_updated') { + $notification->setParsedSubject($l10n->t('Profile field updated')); + } elseif ($notification->getParsedSubject() === '') { + $notification->setParsedSubject($notification->getSubject()); + } + + if ($notification->getMessage() === 'profile_field_updated_message') { + $notification->setParsedMessage($l10n->t( + '%1$s changed %2$s\'s %3$s profile field.', + $notification->getMessageParameters(), + )); + } elseif ($notification->getMessage() !== '' && $notification->getParsedMessage() === '') { + $notification->setParsedMessage($notification->getMessage()); + } + + if ($notification->getIcon() === '') { + $notification->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath(Application::APP_ID, 'app.svg'))); + } + + return $notification; + } +} diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index 328c3ed..9f2f2d6 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -18,6 +18,11 @@ use OCA\ProfileFields\Db\FieldValueMapper; use OCA\ProfileFields\Enum\FieldType; use OCA\ProfileFields\Enum\FieldVisibility; +use OCA\ProfileFields\Workflow\Event\ProfileFieldValueCreatedEvent; +use OCA\ProfileFields\Workflow\Event\ProfileFieldValueUpdatedEvent; +use OCA\ProfileFields\Workflow\Event\ProfileFieldVisibilityUpdatedEvent; +use OCA\ProfileFields\Workflow\ProfileFieldValueWorkflowSubject; +use OCP\EventDispatcher\IEventDispatcher; class FieldValueService { private const SEARCH_OPERATOR_EQ = 'eq'; @@ -26,6 +31,7 @@ class FieldValueService { public function __construct( private FieldValueMapper $fieldValueMapper, + private IEventDispatcher $eventDispatcher, ) { } @@ -41,24 +47,46 @@ public function upsert( ?DateTimeInterface $updatedAt = null, ): FieldValue { $normalizedValue = $this->normalizeValue($definition, $rawValue); + $valueJson = $this->encodeValue($normalizedValue); $visibility = $currentVisibility ?? $definition->getInitialVisibility(); if (!FieldVisibility::isValid($visibility)) { throw new InvalidArgumentException('current_visibility is not supported'); } $entity = $this->fieldValueMapper->findByFieldDefinitionIdAndUserUid($definition->getId(), $userUid) ?? new FieldValue(); + $previousValue = $entity->getId() === null ? null : $this->extractScalarValue($entity->getValueJson()); + $previousVisibility = $entity->getId() === null ? null : $entity->getCurrentVisibility(); + $valueChanged = $previousValue !== ($normalizedValue['value'] ?? null); + $visibilityChanged = $previousVisibility !== null && $previousVisibility !== $visibility; $entity->setFieldDefinitionId($definition->getId()); $entity->setUserUid($userUid); - $entity->setValueJson($this->encodeValue($normalizedValue)); + $entity->setValueJson($valueJson); $entity->setCurrentVisibility($visibility); $entity->setUpdatedByUid($updatedByUid); $entity->setUpdatedAt($this->asMutableDateTime($updatedAt)); if ($entity->getId() === null) { - return $this->fieldValueMapper->insert($entity); + $stored = $this->fieldValueMapper->insert($entity); + $this->eventDispatcher->dispatchTyped(new ProfileFieldValueCreatedEvent( + $this->buildWorkflowSubject($definition, $stored, $updatedByUid, null, null), + )); + + return $stored; + } + + $stored = $this->fieldValueMapper->update($entity); + if ($valueChanged) { + $this->eventDispatcher->dispatchTyped(new ProfileFieldValueUpdatedEvent( + $this->buildWorkflowSubject($definition, $stored, $updatedByUid, $previousValue, $previousVisibility), + )); + } + if ($visibilityChanged) { + $this->eventDispatcher->dispatchTyped(new ProfileFieldVisibilityUpdatedEvent( + $this->buildWorkflowSubject($definition, $stored, $updatedByUid, $previousValue, $previousVisibility), + )); } - return $this->fieldValueMapper->update($entity); + return $stored; } /** @@ -153,11 +181,19 @@ public function updateVisibility(FieldDefinition $definition, string $userUid, s throw new InvalidArgumentException('field value not found'); } + $previousValue = $this->extractScalarValue($entity->getValueJson()); + $previousVisibility = $entity->getCurrentVisibility(); + $entity->setCurrentVisibility($currentVisibility); $entity->setUpdatedByUid($updatedByUid); $entity->setUpdatedAt($this->asMutableDateTime()); - return $this->fieldValueMapper->update($entity); + $stored = $this->fieldValueMapper->update($entity); + $this->eventDispatcher->dispatchTyped(new ProfileFieldVisibilityUpdatedEvent( + $this->buildWorkflowSubject($definition, $stored, $updatedByUid, $previousValue, $previousVisibility), + )); + + return $stored; } /** @@ -235,6 +271,31 @@ private function decodeValue(string $valueJson): array { return $decoded; } + private function extractScalarValue(string $valueJson): string|int|float|bool|null { + $decoded = $this->decodeValue($valueJson); + $value = $decoded['value'] ?? null; + + return is_array($value) || is_object($value) ? null : $value; + } + + private function buildWorkflowSubject( + FieldDefinition $definition, + FieldValue $value, + string $actorUid, + string|int|float|bool|null $previousValue, + ?string $previousVisibility, + ): ProfileFieldValueWorkflowSubject { + return new ProfileFieldValueWorkflowSubject( + userUid: $value->getUserUid(), + actorUid: $actorUid, + fieldDefinition: $definition, + currentValue: $this->extractScalarValue($value->getValueJson()), + previousValue: $previousValue, + currentVisibility: $value->getCurrentVisibility(), + previousVisibility: $previousVisibility, + ); + } + /** * @param array|scalar|null $rawValue * @return array{value: mixed} diff --git a/lib/Workflow/CreateTalkConversationProfileFieldChangeOperation.php b/lib/Workflow/CreateTalkConversationProfileFieldChangeOperation.php new file mode 100644 index 0000000..57b87c3 --- /dev/null +++ b/lib/Workflow/CreateTalkConversationProfileFieldChangeOperation.php @@ -0,0 +1,104 @@ +l10n->t('Create Talk conversation'); + } + + #[\Override] + public function getDescription(): string { + return $this->l10n->t('Create a Talk conversation with the affected user and administrators when a profile field change matches the workflow rule.'); + } + + #[\Override] + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/comment.svg'); + } + + #[\Override] + public function isAvailableForScope(int $scope): bool { + return $scope === IManager::SCOPE_ADMIN; + } + + #[\Override] + public function validateOperation(string $name, array $checks, string $operation): void { + if (trim($operation) !== '') { + throw new \UnexpectedValueException($this->l10n->t('This workflow operation does not accept custom configuration')); + } + } + + #[\Override] + public function onEvent(string $eventName, Event $event, IRuleMatcher $ruleMatcher): void { + if (!$event instanceof AbstractProfileFieldValueEvent) { + return; + } + + try { + $matches = $ruleMatcher->getFlows(false); + if ($matches === [] || !$this->broker->hasBackend()) { + return; + } + + $subject = $event->getWorkflowSubject(); + $fieldDefinition = $subject->getFieldDefinition(); + $fieldLabel = trim($fieldDefinition->getLabel()) !== '' ? $fieldDefinition->getLabel() : $fieldDefinition->getFieldKey(); + $moderators = []; + + $affectedUser = $this->userManager->get($subject->getUserUid()); + if ($affectedUser instanceof IUser) { + $moderators[$affectedUser->getUID()] = $affectedUser; + } + + foreach ($this->groupManager->get('admin')?->getUsers() ?? [] as $adminUser) { + $moderators[$adminUser->getUID()] = $adminUser; + } + + if ($moderators === []) { + return; + } + + $this->broker->createConversation( + $this->l10n->t('Profile field change: %1$s for %2$s', [ + $fieldLabel, + $subject->getUserUid(), + ]), + array_values($moderators), + null, + ); + } finally { + $this->workflowSubjectContext->clear(); + } + } +} diff --git a/lib/Workflow/EmailUserProfileFieldChangeOperation.php b/lib/Workflow/EmailUserProfileFieldChangeOperation.php new file mode 100644 index 0000000..5e7f013 --- /dev/null +++ b/lib/Workflow/EmailUserProfileFieldChangeOperation.php @@ -0,0 +1,161 @@ +l10n->t('Email affected user'); + } + + #[\Override] + public function getDescription(): string { + return $this->l10n->t('Send an email to the affected user when a profile field change matches the workflow rule.'); + } + + #[\Override] + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/mail.svg'); + } + + #[\Override] + public function isAvailableForScope(int $scope): bool { + return $scope === IManager::SCOPE_ADMIN; + } + + #[\Override] + public function validateOperation(string $name, array $checks, string $operation): void { + if ($this->parseConfig($operation) === null) { + throw new \UnexpectedValueException($this->l10n->t('A valid email template configuration is required')); + } + } + + #[\Override] + public function onEvent(string $eventName, Event $event, IRuleMatcher $ruleMatcher): void { + if (!$event instanceof AbstractProfileFieldValueEvent) { + return; + } + + try { + $matches = $ruleMatcher->getFlows(false); + if ($matches === []) { + return; + } + + $subject = $event->getWorkflowSubject(); + $config = $this->parseConfig((string)($matches[0]['operation'] ?? '')) ?? $this->defaultConfig(); + $user = $this->userManager->get($subject->getUserUid()); + if ($user === null) { + return; + } + + $emailAddress = trim((string)$user->getEMailAddress()); + if ($emailAddress === '') { + return; + } + + $displayName = trim((string)$user->getDisplayName()); + if ($displayName === '') { + $displayName = $subject->getUserUid(); + } + + $fieldDefinition = $subject->getFieldDefinition(); + $fieldLabel = trim($fieldDefinition->getLabel()) !== '' ? $fieldDefinition->getLabel() : $fieldDefinition->getFieldKey(); + + $message = $this->mailer->createMessage(); + $message + ->setTo([$emailAddress => $displayName]) + ->setSubject($this->renderTemplate($config['subjectTemplate'], $subject->getActorUid(), $subject->getUserUid(), $fieldDefinition->getFieldKey(), $fieldLabel, $subject->getPreviousValue(), $subject->getCurrentValue(), $subject->getPreviousVisibility(), $subject->getCurrentVisibility())) + ->setPlainBody($this->renderTemplate($config['bodyTemplate'], $subject->getActorUid(), $subject->getUserUid(), $fieldDefinition->getFieldKey(), $fieldLabel, $subject->getPreviousValue(), $subject->getCurrentValue(), $subject->getPreviousVisibility(), $subject->getCurrentVisibility())); + + $this->mailer->send($message); + } finally { + $this->workflowSubjectContext->clear(); + } + } + + private function normalizeValue(?string $value): string { + $normalized = trim((string)$value); + return $normalized !== '' ? $normalized : $this->l10n->t('(empty)'); + } + + /** + * @return array{subjectTemplate: string, bodyTemplate: string}|null + */ + private function parseConfig(string $operation): ?array { + $config = trim($operation); + if ($config === '') { + return $this->defaultConfig(); + } + + try { + $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + if (!is_array($decoded)) { + return null; + } + + $subjectTemplate = trim((string)($decoded['subjectTemplate'] ?? '')); + $bodyTemplate = trim((string)($decoded['bodyTemplate'] ?? '')); + if ($subjectTemplate === '' || $bodyTemplate === '') { + return null; + } + + return [ + 'subjectTemplate' => $subjectTemplate, + 'bodyTemplate' => $bodyTemplate, + ]; + } + + /** + * @return array{subjectTemplate: string, bodyTemplate: string} + */ + private function defaultConfig(): array { + return [ + 'subjectTemplate' => 'Your profile field was updated', + 'bodyTemplate' => 'Your profile field "{{fieldLabel}}" was updated by {{actorUid}}.' . "\n\n" . 'Previous value: {{previousValue}}' . "\n" . 'Current value: {{currentValue}}' . "\n" . 'Previous visibility: {{previousVisibility}}' . "\n" . 'Current visibility: {{currentVisibility}}', + ]; + } + + private function renderTemplate(string $template, string $actorUid, string $userUid, string $fieldKey, string $fieldLabel, ?string $previousValue, ?string $currentValue, ?string $previousVisibility, ?string $currentVisibility): string { + return strtr($template, [ + '{{actorUid}}' => $actorUid, + '{{userUid}}' => $userUid, + '{{fieldKey}}' => $fieldKey, + '{{fieldLabel}}' => $fieldLabel, + '{{previousValue}}' => $this->normalizeValue($previousValue), + '{{currentValue}}' => $this->normalizeValue($currentValue), + '{{previousVisibility}}' => $this->normalizeValue($previousVisibility), + '{{currentVisibility}}' => $this->normalizeValue($currentVisibility), + ]); + } +} diff --git a/lib/Workflow/Event/AbstractProfileFieldValueEvent.php b/lib/Workflow/Event/AbstractProfileFieldValueEvent.php new file mode 100644 index 0000000..349f4c6 --- /dev/null +++ b/lib/Workflow/Event/AbstractProfileFieldValueEvent.php @@ -0,0 +1,25 @@ +workflowSubject; + } +} diff --git a/lib/Workflow/Event/ProfileFieldValueCreatedEvent.php b/lib/Workflow/Event/ProfileFieldValueCreatedEvent.php new file mode 100644 index 0000000..2b6977d --- /dev/null +++ b/lib/Workflow/Event/ProfileFieldValueCreatedEvent.php @@ -0,0 +1,13 @@ +l10n->t('Log profile field change'); + } + + #[\Override] + public function getDescription(): string { + return $this->l10n->t('Write a server log entry when a profile field change matches the workflow rule.'); + } + + #[\Override] + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/history.svg'); + } + + #[\Override] + public function isAvailableForScope(int $scope): bool { + return $scope === IManager::SCOPE_ADMIN; + } + + #[\Override] + public function validateOperation(string $name, array $checks, string $operation): void { + if (trim($operation) !== '') { + throw new \UnexpectedValueException($this->l10n->t('This workflow operation does not accept custom configuration')); + } + } + + #[\Override] + public function onEvent(string $eventName, Event $event, IRuleMatcher $ruleMatcher): void { + if (!$event instanceof AbstractProfileFieldValueEvent) { + return; + } + + try { + $matches = $ruleMatcher->getFlows(false); + if ($matches === []) { + return; + } + + $subject = $event->getWorkflowSubject(); + foreach ($matches as $match) { + $this->logger->warning('Profile field workflow rule matched', [ + 'app' => 'profile_fields', + 'rule_id' => $match['id'] ?? null, + 'rule_name' => $match['name'] ?? null, + 'field_key' => $subject->getFieldDefinition()->getFieldKey(), + 'user_uid' => $subject->getUserUid(), + 'actor_uid' => $subject->getActorUid(), + 'previous_value' => $subject->getPreviousValue(), + 'current_value' => $subject->getCurrentValue(), + 'previous_visibility' => $subject->getPreviousVisibility(), + 'current_visibility' => $subject->getCurrentVisibility(), + 'event_name' => $eventName, + ]); + } + } finally { + $this->workflowSubjectContext->clear(); + } + } +} diff --git a/lib/Workflow/NotifyAdminsOrGroupsProfileFieldChangeOperation.php b/lib/Workflow/NotifyAdminsOrGroupsProfileFieldChangeOperation.php new file mode 100644 index 0000000..48a5af3 --- /dev/null +++ b/lib/Workflow/NotifyAdminsOrGroupsProfileFieldChangeOperation.php @@ -0,0 +1,185 @@ +l10n->t('Notify admins or groups'); + } + + #[\Override] + public function getDescription(): string { + return $this->l10n->t('Send an internal notification to configured admins, groups, or users when a profile field change matches the workflow rule.'); + } + + #[\Override] + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/user-admin.svg'); + } + + #[\Override] + public function isAvailableForScope(int $scope): bool { + return $scope === IManager::SCOPE_ADMIN; + } + + #[\Override] + public function validateOperation(string $name, array $checks, string $operation): void { + $config = $this->parseConfig($operation); + if ($config === null || $this->resolveRecipientUids($config['targets']) === []) { + throw new \UnexpectedValueException($this->l10n->t('A valid target list is required')); + } + } + + #[\Override] + public function onEvent(string $eventName, Event $event, IRuleMatcher $ruleMatcher): void { + if (!$event instanceof AbstractProfileFieldValueEvent) { + return; + } + + try { + $matches = $ruleMatcher->getFlows(false); + if ($matches === []) { + return; + } + + $subject = $event->getWorkflowSubject(); + $fieldDefinition = $subject->getFieldDefinition(); + $fieldLabel = trim($fieldDefinition->getLabel()) !== '' ? $fieldDefinition->getLabel() : $fieldDefinition->getFieldKey(); + + foreach ($matches as $match) { + $config = $this->parseConfig((string)($match['operation'] ?? '')); + if ($config === null) { + continue; + } + + foreach ($this->resolveRecipientUids($config['targets']) as $recipientUid) { + $subjectText = $this->l10n->t('Profile field updated'); + $messageText = $this->l10n->t( + '%1$s changed %2$s\'s %3$s profile field.', + [ + $subject->getActorUid(), + $subject->getUserUid(), + $fieldLabel, + ], + ); + + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp(Application::APP_ID) + ->setUser($recipientUid) + ->setObject('profile-field-admin-change', sprintf('%s:%s:%s', (string)($match['id'] ?? 'workflow'), $recipientUid, $fieldDefinition->getFieldKey())) + ->setDateTime(new \DateTime()) + ->setSubject('profile_field_updated') + ->setMessage('profile_field_updated_message', [ + $subject->getActorUid(), + $subject->getUserUid(), + $fieldLabel, + ]) + ->setParsedSubject($subjectText) + ->setParsedMessage($messageText) + ->setIcon($this->urlGenerator->getAbsoluteURL($this->getIcon())); + + $this->notificationManager->notify($notification); + } + } + } finally { + $this->workflowSubjectContext->clear(); + } + } + + /** + * @return array{targets: string}|null + */ + private function parseConfig(string $operation): ?array { + $config = trim($operation); + if ($config === '') { + return ['targets' => 'admin']; + } + + try { + $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + if (!is_array($decoded)) { + return null; + } + + $targets = trim((string)($decoded['targets'] ?? '')); + if ($targets === '') { + return null; + } + + return ['targets' => $targets]; + } + + /** + * @return list + */ + private function resolveRecipientUids(string $targets): array { + $resolved = []; + foreach (preg_split('/\s*,\s*/', trim($targets)) ?: [] as $target) { + if ($target === '' || $target === 'invalid') { + continue; + } + + if ($target === 'admin') { + $adminGroup = $this->groupManager->get('admin'); + foreach ($adminGroup?->getUsers() ?? [] as $admin) { + $resolved[$admin->getUID()] = $admin->getUID(); + } + continue; + } + + if (str_starts_with($target, 'user:')) { + $uid = substr($target, 5); + $user = $this->userManager->get($uid); + if ($user instanceof IUser) { + $resolved[$uid] = $uid; + } + continue; + } + + if (str_starts_with($target, 'group:')) { + $group = $this->groupManager->get(substr($target, 6)); + foreach ($group?->getUsers() ?? [] as $user) { + $resolved[$user->getUID()] = $user->getUID(); + } + } + } + + return array_values($resolved); + } +} diff --git a/lib/Workflow/ProfileFieldValueEntity.php b/lib/Workflow/ProfileFieldValueEntity.php new file mode 100644 index 0000000..4e103c3 --- /dev/null +++ b/lib/Workflow/ProfileFieldValueEntity.php @@ -0,0 +1,67 @@ +l10n->t('Profile field value'); + } + + #[\Override] + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/profile.svg'); + } + + #[\Override] + public function getEvents(): array { + return [ + new GenericEntityEvent($this->l10n->t('Profile field value created'), ProfileFieldValueCreatedEvent::class), + new GenericEntityEvent($this->l10n->t('Profile field value updated'), ProfileFieldValueUpdatedEvent::class), + new GenericEntityEvent($this->l10n->t('Profile field visibility updated'), ProfileFieldVisibilityUpdatedEvent::class), + ]; + } + + #[\Override] + public function prepareRuleMatcher(IRuleMatcher $ruleMatcher, string $eventName, Event $event): void { + if (!$event instanceof AbstractProfileFieldValueEvent) { + return; + } + + $this->workflowSubject = $event->getWorkflowSubject(); + $this->workflowSubjectContext->set($this->workflowSubject); + $ruleMatcher->setEntitySubject($this, $this->workflowSubject); + } + + #[\Override] + public function isLegitimatedForUserId(string $userId): bool { + return $this->workflowSubject?->getUserUid() === $userId; + } +} diff --git a/lib/Workflow/ProfileFieldValueSubjectContext.php b/lib/Workflow/ProfileFieldValueSubjectContext.php new file mode 100644 index 0000000..32fe55d --- /dev/null +++ b/lib/Workflow/ProfileFieldValueSubjectContext.php @@ -0,0 +1,26 @@ +workflowSubject = $workflowSubject; + } + + public function get(): ?ProfileFieldValueWorkflowSubject { + return $this->workflowSubject; + } + + public function clear(): void { + $this->workflowSubject = null; + } +} diff --git a/lib/Workflow/ProfileFieldValueWorkflowSubject.php b/lib/Workflow/ProfileFieldValueWorkflowSubject.php new file mode 100644 index 0000000..d6f369f --- /dev/null +++ b/lib/Workflow/ProfileFieldValueWorkflowSubject.php @@ -0,0 +1,53 @@ +userUid; + } + + public function getActorUid(): string { + return $this->actorUid; + } + + public function getFieldDefinition(): FieldDefinition { + return $this->fieldDefinition; + } + + public function getCurrentValue(): string|int|float|bool|null { + return $this->currentValue; + } + + public function getPreviousValue(): string|int|float|bool|null { + return $this->previousValue; + } + + public function getCurrentVisibility(): string { + return $this->currentVisibility; + } + + public function getPreviousVisibility(): ?string { + return $this->previousVisibility; + } +} diff --git a/lib/Workflow/SendWebhookProfileFieldChangeOperation.php b/lib/Workflow/SendWebhookProfileFieldChangeOperation.php new file mode 100644 index 0000000..6ea528a --- /dev/null +++ b/lib/Workflow/SendWebhookProfileFieldChangeOperation.php @@ -0,0 +1,212 @@ +l10n->t('Send webhook'); + } + + #[\Override] + public function getDescription(): string { + return $this->l10n->t('Send a webhook request when a profile field change matches the workflow rule.'); + } + + #[\Override] + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/share.svg'); + } + + #[\Override] + public function isAvailableForScope(int $scope): bool { + return $scope === IManager::SCOPE_ADMIN; + } + + #[\Override] + public function validateOperation(string $name, array $checks, string $operation): void { + if ($this->parseConfig($operation) === null) { + throw new \UnexpectedValueException($this->l10n->t('A valid HTTP or HTTPS webhook URL is required')); + } + } + + #[\Override] + public function onEvent(string $eventName, Event $event, IRuleMatcher $ruleMatcher): void { + if (!$event instanceof AbstractProfileFieldValueEvent) { + return; + } + + try { + $matches = $ruleMatcher->getFlows(false); + if ($matches === []) { + return; + } + + $subject = $event->getWorkflowSubject(); + $fieldDefinition = $subject->getFieldDefinition(); + $fieldLabel = trim($fieldDefinition->getLabel()) !== '' ? $fieldDefinition->getLabel() : $fieldDefinition->getFieldKey(); + + foreach ($matches as $match) { + $config = $this->parseConfig((string)($match['operation'] ?? '')); + if ($config === null) { + continue; + } + + $timestamp = (new \DateTimeImmutable())->format(DATE_ATOM); + $body = json_encode([ + 'app' => Application::APP_ID, + 'event' => [ + 'name' => $eventName, + 'timestamp' => $timestamp, + ], + 'rule' => [ + 'id' => $match['id'] ?? null, + 'name' => $match['name'] ?? null, + ], + 'user' => [ + 'uid' => $subject->getUserUid(), + ], + 'actor' => [ + 'uid' => $subject->getActorUid(), + ], + 'field' => [ + 'key' => $fieldDefinition->getFieldKey(), + 'label' => $fieldLabel, + 'type' => $fieldDefinition->getType(), + ], + 'change' => [ + 'previousValue' => $subject->getPreviousValue(), + 'currentValue' => $subject->getCurrentValue(), + 'previousVisibility' => $subject->getPreviousVisibility(), + 'currentVisibility' => $subject->getCurrentVisibility(), + ], + ], JSON_THROW_ON_ERROR); + + $headers = array_merge([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'X-Profile-Fields-Timestamp' => $timestamp, + ], $config['headers']); + + if ($config['secret'] !== '') { + $headers['X-Profile-Fields-Signature'] = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $config['secret']); + } + + $attempts = $config['retries'] + 1; + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + try { + $this->clientService->newClient()->post($config['url'], [ + 'headers' => $headers, + 'body' => $body, + 'timeout' => $config['timeout'], + ]); + break; + } catch (\Throwable) { + if ($attempt === $attempts) { + break; + } + } + } + } + } finally { + $this->workflowSubjectContext->clear(); + } + } + + /** + * @return array{url: string, secret: string, timeout: int, retries: int, headers: array}|null + */ + private function parseConfig(string $operation): ?array { + $rawConfig = trim($operation); + if ($rawConfig === '') { + return null; + } + + if ($this->isValidWebhookUrl($rawConfig)) { + return [ + 'url' => $rawConfig, + 'secret' => '', + 'timeout' => IClient::DEFAULT_REQUEST_TIMEOUT, + 'retries' => 0, + 'headers' => [], + ]; + } + + try { + $decoded = json_decode($rawConfig, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + if (!is_array($decoded)) { + return null; + } + + $webhookUrl = trim((string)($decoded['url'] ?? '')); + if ($webhookUrl === '' || filter_var($webhookUrl, FILTER_VALIDATE_URL) === false) { + return null; + } + + $scheme = strtolower((string)parse_url($webhookUrl, PHP_URL_SCHEME)); + if ($scheme !== 'http' && $scheme !== 'https') { + return null; + } + + $timeout = (int)($decoded['timeout'] ?? IClient::DEFAULT_REQUEST_TIMEOUT); + $retries = (int)($decoded['retries'] ?? 0); + $headers = []; + if (isset($decoded['headers']) && is_array($decoded['headers'])) { + foreach ($decoded['headers'] as $name => $value) { + $name = trim((string)$name); + if ($name === '') { + continue; + } + $headers[$name] = trim((string)$value); + } + } + + return [ + 'url' => $webhookUrl, + 'secret' => trim((string)($decoded['secret'] ?? '')), + 'timeout' => $timeout > 0 ? $timeout : IClient::DEFAULT_REQUEST_TIMEOUT, + 'retries' => max(0, $retries), + 'headers' => $headers, + ]; + } + + private function isValidWebhookUrl(string $operation): bool { + $webhookUrl = trim($operation); + if ($webhookUrl === '' || filter_var($webhookUrl, FILTER_VALIDATE_URL) === false) { + return false; + } + + $scheme = strtolower((string)parse_url($webhookUrl, PHP_URL_SCHEME)); + return $scheme === 'http' || $scheme === 'https'; + } +} diff --git a/lib/Workflow/UserProfileFieldCheck.php b/lib/Workflow/UserProfileFieldCheck.php new file mode 100644 index 0000000..eae1c22 --- /dev/null +++ b/lib/Workflow/UserProfileFieldCheck.php @@ -0,0 +1,233 @@ +workflowSubjectContext->get(); + $config = $this->parseConfig((string)$value); + $definition = $this->resolveDefinition($config['field_key']); + if ($definition === null || !$this->isOperatorSupported($definition, (string)$operator)) { + return false; + } + + $userUid = $workflowSubject?->getUserUid(); + if ($userUid === null) { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return $operator === self::OPERATOR_IS_NOT_SET; + } + + $userUid = $user->getUID(); + } + + if ($userUid === '') { + return $operator === self::OPERATOR_IS_NOT_SET; + } + + $storedValue = $this->fieldValueService->findByFieldDefinitionIdAndUserUid($definition->getId(), $userUid); + $actualValue = $this->extractActualValue($storedValue); + + return $this->evaluate($definition, (string)$operator, $config['value'], $actualValue); + } catch (\Throwable) { + return false; + } + } + + #[\Override] + public function validateCheck($operator, $value) { + $config = $this->parseConfig((string)$value); + $definition = $this->resolveDefinition($config['field_key']); + if ($definition === null) { + throw new \UnexpectedValueException($this->l10n->t('The selected profile field does not exist'), 2); + } + + if (!$this->isOperatorSupported($definition, (string)$operator)) { + throw new \UnexpectedValueException($this->l10n->t('The selected operator is not supported for this profile field'), 3); + } + + if ($this->operatorRequiresValue((string)$operator)) { + try { + $this->fieldValueService->normalizeValue($definition, $config['value']); + } catch (InvalidArgumentException $exception) { + throw new \UnexpectedValueException($this->l10n->t('The configured comparison value is invalid'), 4, $exception); + } + } + } + + #[\Override] + public function supportedEntities(): array { + return []; + } + + #[\Override] + public function isAvailableForScope(int $scope): bool { + return $scope === IManager::SCOPE_ADMIN; + } + + /** + * @return array{field_key: string, value: string|int|float|bool|null} + */ + private function parseConfig(string $value): array { + try { + $decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new \UnexpectedValueException($this->l10n->t('The workflow check configuration is invalid'), 1, $exception); + } + + if (!is_array($decoded) || !is_string($decoded['field_key'] ?? null) || trim($decoded['field_key']) === '') { + throw new \UnexpectedValueException($this->l10n->t('The workflow check configuration is invalid'), 1); + } + + $valueCandidate = $decoded['value'] ?? null; + if (is_array($valueCandidate) || is_object($valueCandidate)) { + throw new \UnexpectedValueException($this->l10n->t('The workflow check configuration is invalid'), 1); + } + + return [ + 'field_key' => trim($decoded['field_key']), + 'value' => $valueCandidate, + ]; + } + + private function resolveDefinition(string $fieldKey): ?FieldDefinition { + $definition = $this->fieldDefinitionService->findByFieldKey($fieldKey); + + if ($definition === null || !$definition->getActive()) { + return null; + } + + return $definition; + } + + private function isOperatorSupported(FieldDefinition $definition, string $operator): bool { + $operators = match (FieldType::from($definition->getType())) { + FieldType::TEXT => self::TEXT_OPERATORS, + FieldType::NUMBER => self::NUMBER_OPERATORS, + }; + + return in_array($operator, $operators, true); + } + + private function operatorRequiresValue(string $operator): bool { + return !in_array($operator, [self::OPERATOR_IS_SET, self::OPERATOR_IS_NOT_SET], true); + } + + private function extractActualValue(?FieldValue $value): string|int|float|bool|null { + if ($value === null) { + return null; + } + + $serialized = $this->fieldValueService->serializeForResponse($value); + $payload = $serialized['value']['value'] ?? null; + + return is_array($payload) || is_object($payload) ? null : $payload; + } + + private function evaluate(FieldDefinition $definition, string $operator, string|int|float|bool|null $expectedRawValue, string|int|float|bool|null $actualValue): bool { + $isSet = $actualValue !== null && $actualValue !== ''; + if ($operator === self::OPERATOR_IS_SET) { + return $isSet; + } + if ($operator === self::OPERATOR_IS_NOT_SET) { + return !$isSet; + } + if (!$isSet) { + return false; + } + + $normalizedExpected = $this->fieldValueService->normalizeValue($definition, $expectedRawValue); + $expectedValue = $normalizedExpected['value'] ?? null; + + return match (FieldType::from($definition->getType())) { + FieldType::TEXT => $this->evaluateTextOperator($operator, (string)$expectedValue, (string)$actualValue), + FieldType::NUMBER => $this->evaluateNumberOperator( + $operator, + $this->normalizeNumericComparisonOperand($expectedValue), + $this->normalizeNumericComparisonOperand($actualValue), + ), + }; + } + + private function normalizeNumericComparisonOperand(string|int|float|bool|null $value): int|float { + if (is_int($value) || is_float($value)) { + return $value; + } + + return str_contains((string)$value, '.') ? (float)$value : (int)$value; + } + + private function evaluateTextOperator(string $operator, string $expectedValue, string $actualValue): bool { + return match ($operator) { + 'is' => $actualValue === $expectedValue, + '!is' => $actualValue !== $expectedValue, + 'contains' => $expectedValue !== '' && mb_stripos($actualValue, $expectedValue) !== false, + '!contains' => $expectedValue === '' || mb_stripos($actualValue, $expectedValue) === false, + default => false, + }; + } + + private function evaluateNumberOperator(string $operator, int|float $expectedValue, int|float $actualValue): bool { + return match ($operator) { + 'is' => $actualValue === $expectedValue, + '!is' => $actualValue !== $expectedValue, + 'less' => $actualValue < $expectedValue, + '!greater' => $actualValue <= $expectedValue, + 'greater' => $actualValue > $expectedValue, + '!less' => $actualValue >= $expectedValue, + default => false, + }; + } +} diff --git a/playwright/e2e/workflow.spec.ts b/playwright/e2e/workflow.spec.ts new file mode 100644 index 0000000..e7fcbac --- /dev/null +++ b/playwright/e2e/workflow.spec.ts @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { expect, test, type Locator, type Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { createDefinition, deleteDefinitionByFieldKey } from '../support/profile-fields' + +test.describe.configure({ mode: 'serial' }) + +const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const selectNcOption = async(page: Page, combobox: Locator, optionName: string) => { + await combobox.click() + await page.locator('[role="option"]').filter({ + hasText: new RegExp(`^\\s*${escapeRegex(optionName)}\\s*$`), + }).first().click() +} + +const ensureFlowCardIsVisible = async(page: Page, addFlowCard: Locator) => { + if (await addFlowCard.count() > 0) { + return + } + + const showMoreButton = page.getByRole('button', { name: 'Show more', exact: true }) + if (await showMoreButton.count() > 0) { + await showMoreButton.click() + } + + await expect(addFlowCard).toBeVisible() +} + +const configureDraftRule = async(page: Page, actionName: string, label: string, fieldValue: string, configureOperation?: (configuredRule: Locator) => Promise, operationValue?: string) => { + const initialRuleCount = await page.locator('.section.rule').count() + const addFlowCard = page.locator('.actions__item.colored').filter({ + has: page.getByRole('heading', { name: actionName, exact: true }), + }) + await ensureFlowCardIsVisible(page, addFlowCard) + await addFlowCard.getByRole('button', { name: 'Add new flow' }).click() + + const configuredRule = page.locator('.section.rule').filter({ + has: page.getByRole('button', { name: 'Cancel', exact: true }), + }).last() + await expect(configuredRule).toBeVisible() + const configuredRuleIndex = await configuredRule.evaluate((element) => { + return Array.from(document.querySelectorAll('.section.rule')).indexOf(element) + }) + + await expect(configuredRule.getByText('Profile field value updated', { exact: true })).toBeVisible() + await selectNcOption(page, configuredRule.getByRole('combobox', { name: 'Select a filter' }), 'Profile field value') + await selectNcOption(page, configuredRule.locator('.comparator [role="combobox"]'), 'is') + + const checkEditor = configuredRule.locator('oca-profile-fields-check-user-profile-field') + await expect(checkEditor).toBeVisible() + await checkEditor.locator('select').selectOption({ label }) + await checkEditor.locator('input').fill(fieldValue) + + if (operationValue !== undefined) { + const operationInput = configuredRule.locator('input[type="url"]') + await expect(operationInput).toBeVisible() + await operationInput.fill(operationValue) + } + + if (configureOperation !== undefined) { + await configureOperation(configuredRule) + } + + await expect(configuredRule.getByRole('button', { name: 'Save' })).toBeVisible() + await configuredRule.getByRole('button', { name: 'Save' }).click() + + await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount + 1) + const savedRule = page.locator('.section.rule').nth(configuredRuleIndex) + await expect(savedRule.getByText('Profile field value updated', { exact: true })).toBeVisible() + await expect(savedRule.getByRole('button', { name: 'Active' })).toBeVisible() + + return { savedRule, initialRuleCount } +} + +const createWorkflowFieldDefinition = async(page: Page, fieldKey: string, label: string) => { + await deleteDefinitionByFieldKey(page.request, fieldKey) + await createDefinition(page.request, { + fieldKey, + label, + userEditable: true, + userVisible: true, + initialVisibility: 'users', + }) +} + +test.beforeEach(async ({ page }) => { + await login(page.request, adminUser, adminPassword) +}) + +test('admin can create a profile field workflow rule', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_workflow_${suffix}` + const label = `Playwright workflow ${suffix}` + const fieldValue = `engineering-${suffix}` + + await createWorkflowFieldDefinition(page, fieldKey, label) + + await page.goto('./settings/admin/workflow') + await expect(page.getByRole('heading', { name: 'Available flows' })).toBeVisible() + const { savedRule, initialRuleCount } = await configureDraftRule(page, 'Log profile field change', label, fieldValue) + + await savedRule.getByRole('button', { name: 'Delete' }).click() + await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount) + await deleteDefinitionByFieldKey(page.request, fieldKey) +}) + +test('admin can create a send webhook workflow rule', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_webhook_workflow_${suffix}` + const label = `Playwright webhook workflow ${suffix}` + const fieldValue = `engineering-webhook-${suffix}` + const webhookUrl = `https://example.test/hooks/profile-fields/${suffix}` + + await createWorkflowFieldDefinition(page, fieldKey, label) + + await page.goto('./settings/admin/workflow') + await expect(page.getByRole('heading', { name: 'Available flows' })).toBeVisible() + const { savedRule, initialRuleCount } = await configureDraftRule(page, 'Send webhook', label, fieldValue, async (configuredRule) => { + await configuredRule.locator(`input[placeholder="Optional shared secret for HMAC signatures"]`).fill(`secret-${suffix}`) + await configuredRule.locator(`input[placeholder="Timeout in seconds"]`).fill('10') + }, webhookUrl) + + await savedRule.getByRole('button', { name: 'Delete' }).click() + await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount) + await deleteDefinitionByFieldKey(page.request, fieldKey) +}) + +test('admin can create an email affected user workflow rule', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_email_workflow_${suffix}` + const label = `Playwright email workflow ${suffix}` + const fieldValue = `engineering-email-${suffix}` + + await createWorkflowFieldDefinition(page, fieldKey, label) + + await page.goto('./settings/admin/workflow') + await expect(page.getByRole('heading', { name: 'Available flows' })).toBeVisible() + const { savedRule, initialRuleCount } = await configureDraftRule(page, 'Email affected user', label, fieldValue, async (configuredRule) => { + await configuredRule.locator(`input[placeholder="Optional email subject template"]`).fill('Update: {{fieldLabel}}') + await configuredRule.locator('textarea').fill('Field {{fieldLabel}} changed from {{previousValue}} to {{currentValue}}.') + }) + + await savedRule.getByRole('button', { name: 'Delete' }).click() + await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount) + await deleteDefinitionByFieldKey(page.request, fieldKey) +}) + +test('admin can create a notify admins or groups workflow rule', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_admin_notify_workflow_${suffix}` + const label = `Playwright admin notify workflow ${suffix}` + const fieldValue = `engineering-admin-notify-${suffix}` + + await createWorkflowFieldDefinition(page, fieldKey, label) + + await page.goto('./settings/admin/workflow') + await expect(page.getByRole('heading', { name: 'Available flows' })).toBeVisible() + const { savedRule, initialRuleCount } = await configureDraftRule(page, 'Notify admins or groups', label, fieldValue, async (configuredRule) => { + const targetsEditor = configuredRule.locator('oca-profile-fields-targets-operation') + await expect(targetsEditor).toBeVisible() + + await targetsEditor.getByRole('combobox').click() + await targetsEditor.getByRole('searchbox').fill('admin') + await expect(page.locator('.vs__dropdown-menu')).toBeVisible() + await page.locator('.vs__dropdown-option').filter({ hasText: /Administrators/ }).first().click() + await expect(targetsEditor).toContainText('Administrators') + }) + + await savedRule.getByRole('button', { name: 'Delete' }).click() + await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount) + await deleteDefinitionByFieldKey(page.request, fieldKey) +}) + +test('admin can create a create Talk conversation workflow rule', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_talk_workflow_${suffix}` + const label = `Playwright talk workflow ${suffix}` + const fieldValue = `engineering-talk-${suffix}` + + await createWorkflowFieldDefinition(page, fieldKey, label) + + await page.goto('./settings/admin/workflow') + await expect(page.getByRole('heading', { name: 'Available flows' })).toBeVisible() + const { savedRule, initialRuleCount } = await configureDraftRule(page, 'Create Talk conversation', label, fieldValue) + + await savedRule.getByRole('button', { name: 'Delete' }).click() + await expect(page.locator('.section.rule')).toHaveCount(initialRuleCount) + await deleteDefinitionByFieldKey(page.request, fieldKey) +}) diff --git a/playwright/fixtures/pedro-poti-avatar.png b/playwright/fixtures/pedro-poti-avatar.png new file mode 100644 index 0000000..f4cb7eb Binary files /dev/null and b/playwright/fixtures/pedro-poti-avatar.png differ diff --git a/playwright/generate-screenshots.mjs b/playwright/generate-screenshots.mjs index 51ecea5..e77be3c 100644 --- a/playwright/generate-screenshots.mjs +++ b/playwright/generate-screenshots.mjs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { rmSync } from 'node:fs' -import { mkdir, rm } from 'node:fs/promises' +import { mkdir, readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { chromium, request } from '@playwright/test' @@ -11,13 +11,17 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost' const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' const screenshotDir = 'img/screenshots' -const storageStatePath = 'playwright/.tmp-storage-state.json' +const adminStorageStatePath = 'playwright/.tmp-admin-storage-state.json' +const demoStorageStatePath = 'playwright/.tmp-demo-storage-state.json' +const demoAvatarPath = 'playwright/fixtures/pedro-poti-avatar.png' + +const legacyDemoUserIds = ['amina_okafor_demo', 'araci_potira_demo'] const demoUser = { - id: 'amina_okafor_demo', - password: 'AminaDemoPass123!', - displayName: 'Amina Okafor', - email: 'amina.okafor@example.net', + id: 'pedro_poti_demo', + password: 'PedroDemoPass123!', + displayName: 'Pedro Poti', + email: 'pedro.poti@example.net', } const showcaseFields = [ @@ -97,16 +101,21 @@ const showcaseFields = [ const showcaseKeys = new Set(showcaseFields.map((field) => field.fieldKey)) const showcaseLabels = new Set(showcaseFields.map((field) => field.label)) +const transientFieldKeyPrefixes = ['showcase_', 'playwright_'] + +const isTransientScreenshotDefinition = (definition) => transientFieldKeyPrefixes + .some((prefix) => definition.field_key.startsWith(prefix)) + -async function loginApi() { +async function loginApi(user = adminUser, password = adminPassword) { const api = await request.newContext({ baseURL, ignoreHTTPSErrors: true }) const tokenResponse = await api.get('./csrftoken') const { token: requesttoken } = await tokenResponse.json() const origin = tokenResponse.url().replace(/index\.php.*/, '') const loginResponse = await api.post('./login', { form: { - user: adminUser, - password: adminPassword, + user, + password, requesttoken, }, headers: { @@ -123,6 +132,43 @@ async function loginApi() { return api } +async function getRequestToken(api) { + const response = await api.get('./csrftoken') + const parsed = await response.json() + return parsed.token +} + +async function uploadCurrentUserAvatar(api, imagePath) { + const imageBuffer = await readFile(imagePath) + const requesttoken = await getRequestToken(api) + const response = await api.post('./avatar/', { + headers: { + requesttoken, + }, + multipart: { + 'files[]': { + name: imagePath.split('/').pop() ?? 'avatar.png', + mimeType: 'image/png', + buffer: imageBuffer, + }, + }, + failOnStatusCode: false, + }) + + const body = await response.text() + let parsed + + try { + parsed = JSON.parse(body) + } catch { + throw new Error(`Avatar upload failed: ${response.status()} ${body}`) + } + + if (!response.ok() || parsed.status !== 'success') { + throw new Error(`Avatar upload failed: ${response.status()} ${body}`) + } +} + async function appRequest(api, method, path, body) { const headers = { 'OCS-APIRequest': 'true', @@ -157,6 +203,10 @@ async function createDemoUser(api) { Accept: 'application/json', } + for (const legacyUserId of legacyDemoUserIds) { + await api.delete(`./ocs/v1.php/cloud/users/${legacyUserId}`, { headers, failOnStatusCode: false }) + } + await api.delete(`./ocs/v1.php/cloud/users/${demoUser.id}`, { headers, failOnStatusCode: false }) const response = await api.post('./ocs/v1.php/cloud/users', { headers, @@ -177,6 +227,16 @@ async function createDemoUser(api) { } async function deleteDemoUser(api) { + for (const legacyUserId of legacyDemoUserIds) { + await api.delete(`./ocs/v1.php/cloud/users/${legacyUserId}`, { + headers: { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + }, + failOnStatusCode: false, + }) + } + await api.delete(`./ocs/v1.php/cloud/users/${demoUser.id}`, { headers: { 'OCS-APIRequest': 'true', @@ -237,6 +297,25 @@ const hideNonShowcaseDialogFields = async(page) => { }, { labels: [...showcaseLabels], demoUserId: demoUser.id }) } +const prepareWorkflowScreenshot = async(page) => { + await page.goto('./settings/admin/workflow') + await page.getByRole('heading', { name: 'Available flows' }).waitFor({ state: 'visible', timeout: 60_000 }) + + const showMoreButton = page.getByRole('button', { name: 'Show more', exact: true }) + if (await showMoreButton.count() > 0) { + await showMoreButton.click() + } + await page.getByRole('heading', { name: 'Create Talk conversation', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) + + await page.evaluate(() => { + document.querySelector('header')?.setAttribute('style', 'display:none') + document.querySelector('#app-navigation')?.setAttribute('style', 'display:none') + document.querySelector('.settings-menu')?.setAttribute('style', 'display:none') + }) + + return page.locator('#workflowengine .settings-section').first() +} + const generateThumbnail = (inputName, outputName) => { const result = spawnSync('magick', [ join(screenshotDir, inputName), @@ -257,10 +336,13 @@ const cleanupOutput = async() => { rmSync(join(screenshotDir, 'personal-settings-thumb.png'), { force: true }) rmSync(join(screenshotDir, 'user-management-dialog.png'), { force: true }) rmSync(join(screenshotDir, 'user-management-dialog-thumb.png'), { force: true }) + rmSync(join(screenshotDir, 'workflow-notify-admins.png'), { force: true }) + rmSync(join(screenshotDir, 'workflow-notify-admins-thumb.png'), { force: true }) } const run = async() => { const api = await loginApi() + let demoApi const createdIds = [] let browser @@ -270,12 +352,14 @@ const run = async() => { const existingDefinitions = await appRequest(api, 'GET', './ocs/v2.php/apps/profile_fields/api/v1/definitions') for (const definition of existingDefinitions) { - if (showcaseKeys.has(definition.field_key)) { + if (isTransientScreenshotDefinition(definition)) { await appRequest(api, 'DELETE', `./ocs/v2.php/apps/profile_fields/api/v1/definitions/${definition.id}`) } } await createDemoUser(api) + demoApi = await loginApi(demoUser.id, demoUser.password) + await uploadCurrentUserAvatar(demoApi, demoAvatarPath) for (const field of showcaseFields) { const definition = await appRequest(api, 'POST', './ocs/v2.php/apps/profile_fields/api/v1/definitions', { @@ -295,30 +379,38 @@ const run = async() => { await appRequest(api, 'PUT', `./ocs/v2.php/apps/profile_fields/api/v1/users/${encodeURIComponent(demoUser.id)}/values/${definition.id}`, field.demoValue) } - await api.storageState({ path: storageStatePath }) + await api.storageState({ path: adminStorageStatePath }) + await demoApi.storageState({ path: demoStorageStatePath }) browser = await chromium.launch({ headless: true }) - const context = await browser.newContext({ + const adminContext = await browser.newContext({ baseURL, ignoreHTTPSErrors: true, - storageState: storageStatePath, + storageState: adminStorageStatePath, + viewport: { width: 1680, height: 1500 }, + deviceScaleFactor: 2, + }) + const demoContext = await browser.newContext({ + baseURL, + ignoreHTTPSErrors: true, + storageState: demoStorageStatePath, viewport: { width: 1680, height: 1500 }, deviceScaleFactor: 2, }) - const adminPage = await context.newPage() + const adminPage = await adminContext.newPage() await adminPage.goto('./settings/admin/profile_fields') await adminPage.getByTestId('profile-fields-admin-definition-showcase_support_region').waitFor({ state: 'visible', timeout: 60_000 }) await hideNonShowcaseAdminDefinitions(adminPage) await adminPage.getByTestId('profile-fields-admin-definition-showcase_support_region').click() await adminPage.locator('[data-testid="profile-fields-admin"]').screenshot({ path: join(screenshotDir, 'admin-catalog.png'), type: 'png' }) - const personalPage = await context.newPage() + const personalPage = await demoContext.newPage() await personalPage.goto('./settings/user/personal-info') await personalPage.getByTestId('profile-fields-personal-field-showcase_support_region').waitFor({ state: 'visible', timeout: 60_000 }) await hideNonShowcasePersonalFields(personalPage) await personalPage.locator('main').screenshot({ path: join(screenshotDir, 'personal-settings.png'), type: 'png' }) - const usersPage = await context.newPage() + const usersPage = await adminContext.newPage() await usersPage.goto('./settings/users') const demoRow = usersPage.getByRole('row', { name: new RegExp(demoUser.displayName) }) await demoRow.waitFor({ state: 'visible', timeout: 60_000 }) @@ -326,12 +418,19 @@ const run = async() => { await usersPage.getByRole('menuitem', { name: 'Edit profile fields' }).click() const dialog = usersPage.locator('.profile-fields-user-dialog') await dialog.waitFor({ state: 'visible', timeout: 60_000 }) + await usersPage.locator('.profile-fields-user-dialog__loading').waitFor({ state: 'hidden', timeout: 60_000 }).catch(() => {}) + await dialog.locator('.profile-fields-user-dialog__row').first().waitFor({ state: 'visible', timeout: 60_000 }) await hideNonShowcaseDialogFields(usersPage) await dialog.screenshot({ path: join(screenshotDir, 'user-management-dialog.png'), type: 'png' }) + const workflowPage = await adminContext.newPage() + const workflowSection = await prepareWorkflowScreenshot(workflowPage) + await workflowSection.screenshot({ path: join(screenshotDir, 'workflow-notify-admins.png'), type: 'png' }) + generateThumbnail('admin-catalog.png', 'admin-catalog-thumb.png') generateThumbnail('personal-settings.png', 'personal-settings-thumb.png') generateThumbnail('user-management-dialog.png', 'user-management-dialog-thumb.png') + generateThumbnail('workflow-notify-admins.png', 'workflow-notify-admins-thumb.png') console.log('Generated screenshots in', screenshotDir) } finally { @@ -353,8 +452,13 @@ const run = async() => { console.error('Failed to delete demo user:', error) } + if (demoApi) { + await demoApi.dispose() + } + await api.dispose() - await rm(storageStatePath, { force: true }) + await rm(adminStorageStatePath, { force: true }) + await rm(demoStorageStatePath, { force: true }) } } diff --git a/src/api.ts b/src/api.ts index 36c8572..651e542 100644 --- a/src/api.ts +++ b/src/api.ts @@ -16,6 +16,12 @@ import type { UpsertOwnValuePayload, } from './types' +export type WorkflowTargetSuggestion = { + token: string + label: string + description: string +} + const jsonHeaders = { 'OCS-APIRequest': 'true', Accept: 'application/json', @@ -88,3 +94,31 @@ export const upsertAdminUserValue = async(userUid: string, fieldDefinitionId: nu }) return response.data.ocs.data } + +export const searchWorkflowTargetSuggestions = async(search: string, limit = 5): Promise => { + const trimmedSearch = search.trim() + const [groupsResponse, usersResponse] = await Promise.all([ + axios.get<{ ocs: { data: { groups?: Array<{ id: string, displayname?: string }> } } }>( + generateOcsUrl('/cloud/groups/details?search={search}&offset={offset}&limit={limit}', { search: trimmedSearch, offset: 0, limit }), + { headers: { 'OCS-APIRequest': 'true' } }, + ), + axios.get<{ ocs: { data: { users?: Record } } }>( + generateOcsUrl('/cloud/users/details?offset={offset}&limit={limit}&search={search}', { offset: 0, limit, search: trimmedSearch }), + { headers: { 'OCS-APIRequest': 'true' } }, + ), + ]) + + const groups = (groupsResponse.data.ocs?.data?.groups ?? []).map((group) => ({ + token: `group:${group.id}`, + label: group.displayname?.trim() || group.id, + description: `Group: ${group.id}`, + })) + + const users = Object.entries(usersResponse.data.ocs?.data?.users ?? {}).map(([uid, user]) => ({ + token: `user:${uid}`, + label: user.displayname?.trim() || uid, + description: `User: ${uid}`, + })) + + return [...groups, ...users] +} diff --git a/src/components/WorkflowTargetsSelect.vue b/src/components/WorkflowTargetsSelect.vue new file mode 100644 index 0000000..782c52b --- /dev/null +++ b/src/components/WorkflowTargetsSelect.vue @@ -0,0 +1,164 @@ + + + + + + + diff --git a/src/tests/utils/workflowProfileFieldCheck.spec.ts b/src/tests/utils/workflowProfileFieldCheck.spec.ts new file mode 100644 index 0000000..3feddb4 --- /dev/null +++ b/src/tests/utils/workflowProfileFieldCheck.spec.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it } from 'vitest' + +import { + getWorkflowOperatorKeys, + isWorkflowOperatorSupported, + parseWorkflowCheckValue, + serializeWorkflowCheckValue, +} from '../../utils/workflowProfileFieldCheck.ts' + +const definitions = [ + { field_key: 'region', label: 'Region', type: 'text', active: true }, + { field_key: 'score', label: 'Score', type: 'number', active: true }, +] as const + +describe('workflowProfileFieldCheck', () => { + it('serializes and parses workflow values consistently', () => { + const encoded = serializeWorkflowCheckValue({ field_key: 'region', value: 'LATAM' }) + + expect(parseWorkflowCheckValue(encoded)).toEqual({ field_key: 'region', value: 'LATAM' }) + }) + + it('returns text operators for text definitions', () => { + expect(getWorkflowOperatorKeys(serializeWorkflowCheckValue({ field_key: 'region', value: 'LATAM' }), definitions)).toEqual([ + 'is-set', + '!is-set', + 'is', + '!is', + 'contains', + '!contains', + ]) + }) + + it('returns numeric operators for number definitions', () => { + expect(getWorkflowOperatorKeys(serializeWorkflowCheckValue({ field_key: 'score', value: '9' }), definitions)).toEqual([ + 'is-set', + '!is-set', + 'is', + '!is', + 'less', + '!greater', + 'greater', + '!less', + ]) + }) + + it('rejects unsupported operators for the selected field type', () => { + expect(isWorkflowOperatorSupported('contains', serializeWorkflowCheckValue({ field_key: 'score', value: '9' }), definitions)).toBe(false) + expect(isWorkflowOperatorSupported('greater', serializeWorkflowCheckValue({ field_key: 'score', value: '9' }), definitions)).toBe(true) + }) +}) diff --git a/src/utils/workflowProfileFieldCheck.ts b/src/utils/workflowProfileFieldCheck.ts new file mode 100644 index 0000000..12634af --- /dev/null +++ b/src/utils/workflowProfileFieldCheck.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { FieldType } from '../types/index.ts' + +export type WorkflowCheckValue = { + field_key: string + value: string | number | boolean | null +} + +export type WorkflowCheckDefinition = { + field_key: string + label: string + type: FieldType + active: boolean +} + +const textOperatorKeys = ['is-set', '!is-set', 'is', '!is', 'contains', '!contains'] as const +const numberOperatorKeys = ['is-set', '!is-set', 'is', '!is', 'less', '!greater', 'greater', '!less'] as const +const fallbackOperatorKeys = ['is-set', '!is-set', 'is', '!is', 'contains', '!contains', 'less', '!greater', 'greater', '!less'] as const + +export const parseWorkflowCheckValue = (rawValue: string | null | undefined): WorkflowCheckValue | null => { + if (typeof rawValue !== 'string' || rawValue.trim() === '') { + return null + } + + try { + const parsed = JSON.parse(rawValue) as Partial + if (typeof parsed.field_key !== 'string' || parsed.field_key.trim() === '') { + return null + } + if (Array.isArray(parsed.value) || (typeof parsed.value === 'object' && parsed.value !== null)) { + return null + } + + return { + field_key: parsed.field_key.trim(), + value: parsed.value ?? null, + } + } catch { + return null + } +} + +export const serializeWorkflowCheckValue = (value: WorkflowCheckValue): string => JSON.stringify({ + field_key: value.field_key, + value: value.value, +}) + +export const findWorkflowDefinition = (rawValue: string | null | undefined, definitions: readonly WorkflowCheckDefinition[]): WorkflowCheckDefinition | null => { + const parsed = parseWorkflowCheckValue(rawValue) + if (parsed === null) { + return null + } + + return definitions.find((definition) => definition.active && definition.field_key === parsed.field_key) ?? null +} + +export const getWorkflowOperatorKeys = (rawValue: string | null | undefined, definitions: readonly WorkflowCheckDefinition[]): string[] => { + const definition = findWorkflowDefinition(rawValue, definitions) + if (definition === null) { + return [...fallbackOperatorKeys] + } + + return definition.type === 'number' + ? [...numberOperatorKeys] + : [...textOperatorKeys] +} + +export const isWorkflowOperatorSupported = (operator: string | null | undefined, rawValue: string | null | undefined, definitions: readonly WorkflowCheckDefinition[]): boolean => { + if (typeof operator !== 'string' || operator.trim() === '') { + return false + } + + return getWorkflowOperatorKeys(rawValue, definitions).includes(operator) +} + +export const workflowOperatorRequiresValue = (operator: string | null | undefined): boolean => operator !== 'is-set' && operator !== '!is-set' diff --git a/src/workflow.ts b/src/workflow.ts new file mode 100644 index 0000000..d7807bd --- /dev/null +++ b/src/workflow.ts @@ -0,0 +1,1327 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { t } from '@nextcloud/l10n' +import WorkflowTargetsSelect from './components/WorkflowTargetsSelect.vue' +import { createApp, h, reactive, type App } from 'vue' + +import { listDefinitions, searchWorkflowTargetSuggestions, type WorkflowTargetSuggestion } from './api.ts' +import type { FieldDefinition } from './types/index.ts' +import { + getWorkflowOperatorKeys, + isWorkflowOperatorSupported, + parseWorkflowCheckValue, + serializeWorkflowCheckValue, + workflowOperatorRequiresValue, + type WorkflowCheckDefinition, +} from './utils/workflowProfileFieldCheck.ts' + +type WorkflowOperator = { + operator: string + name: string +} + +type WorkflowEnginePlugin = { + class: string + name: string + operators: (check: { value?: string | null }) => WorkflowOperator[] + element: string +} + +type WorkflowEngineOperatorPlugin = { + id: string + operation: string + color: string + element?: string +} + +type WorkflowEmailOperationConfig = { + subjectTemplate: string + bodyTemplate: string +} + +type WorkflowTargetsOperationConfig = { + targets: string +} + +type WorkflowTargetOption = { + id: string + displayName: string + subname?: string + user?: string + isNoUser?: boolean +} + +type WorkflowTargetsViewState = { + disabled: boolean + loading: boolean + options: WorkflowTargetOption[] + selected: WorkflowTargetOption[] +} + +type WorkflowWebhookOperationConfig = { + url: string + secret: string + timeout: string + retries: string + headers: string +} + +type WorkflowEngineApi = { + registerCheck: (plugin: WorkflowEnginePlugin) => void + registerOperator: (plugin: WorkflowEngineOperatorPlugin) => void +} + +type WorkflowEngineEntity = { + id: string + events: Array<{ + eventName: string + displayName: string + }> +} + +type WorkflowEngineRule = { + id: number + class: string + entity: string + events: string[] + name: string + checks: Array<{ class: string | null, operator: string | null, value: string }> + operation: string + valid?: boolean +} + +type WorkflowEngineStore = { + state: { + rules: WorkflowEngineRule[] + entities: WorkflowEngineEntity[] + } + commit: (type: string, payload?: WorkflowEngineRule) => void + dispatch: (type: string, payload?: unknown) => Promise | unknown +} + +type WorkflowEngineRootVm = { + $store: WorkflowEngineStore + createNewRule: (operation: { id: string }) => Promise | unknown +} + +const workflowCheckClass = 'OCA\\ProfileFields\\Workflow\\UserProfileFieldCheck' +const workflowOperationClasses = [ + 'OCA\\ProfileFields\\Workflow\\LogProfileFieldChangeOperation', + 'OCA\\ProfileFields\\Workflow\\EmailUserProfileFieldChangeOperation', + 'OCA\\ProfileFields\\Workflow\\NotifyAdminsOrGroupsProfileFieldChangeOperation', + 'OCA\\ProfileFields\\Workflow\\CreateTalkConversationProfileFieldChangeOperation', + 'OCA\\ProfileFields\\Workflow\\SendWebhookProfileFieldChangeOperation', +] +const workflowEntityClass = 'OCA\\ProfileFields\\Workflow\\ProfileFieldValueEntity' +const workflowUpdatedEventClass = 'OCA\\ProfileFields\\Workflow\\Event\\ProfileFieldValueUpdatedEvent' +const workflowElementId = 'oca-profile-fields-check-user-profile-field' +const emailOperationElementId = 'oca-profile-fields-email-operation' +const targetsOperationElementId = 'oca-profile-fields-targets-operation' +const webhookOperationElementId = 'oca-profile-fields-webhook-operation' +const workflowOperationNames = new Set([ + t('profile_fields', 'Log profile field change'), + t('profile_fields', 'Email affected user'), + t('profile_fields', 'Notify admins or groups'), + t('profile_fields', 'Create Talk conversation'), + t('profile_fields', 'Send webhook'), +]) +const workflowCardClassName = 'profile-fields-workflow-card' +const workflowItemClassName = 'profile-fields-workflow-item' +const workflowCardThemeStyleId = 'profile-fields-workflow-card-theme' + +const operatorLabels: Record = { + 'is-set': t('profile_fields', 'is set'), + '!is-set': t('profile_fields', 'is not set'), + 'is': t('profile_fields', 'is'), + '!is': t('profile_fields', 'is not'), + 'contains': t('profile_fields', 'contains'), + '!contains': t('profile_fields', 'does not contain'), + 'less': t('profile_fields', 'is less than'), + '!greater': t('profile_fields', 'is less than or equal to'), + 'greater': t('profile_fields', 'is greater than'), + '!less': t('profile_fields', 'is greater than or equal to'), +} + +let definitions: WorkflowCheckDefinition[] = [] +let definitionsPromise: Promise | null = null + +const toWorkflowDefinition = (definition: FieldDefinition): WorkflowCheckDefinition => ({ + field_key: definition.field_key, + label: definition.label, + type: definition.type, + active: definition.active, +}) + +const loadDefinitions = async(): Promise => { + if (definitionsPromise === null) { + definitionsPromise = listDefinitions() + .then((items) => items.map(toWorkflowDefinition).filter((item) => item.active)) + .catch(() => []) + .then((items) => { + definitions = items + return items + }) + } + + return definitionsPromise +} + +const dispatchModelValue = (element: HTMLElement, value: string): void => { + element.dispatchEvent(new CustomEvent('update:model-value', { + detail: [value], + bubbles: true, + composed: true, + })) +} + +const dispatchValidity = (element: HTMLElement, valid: boolean): void => { + element.dispatchEvent(new CustomEvent(valid ? 'valid' : 'invalid', { + bubbles: true, + composed: true, + })) +} + +const parseJsonObject = (value: string): Record | null => { + const trimmedValue = value.trim() + if (trimmedValue === '') { + return null + } + + try { + const parsed = JSON.parse(trimmedValue) as unknown + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null + } catch { + return null + } +} + +const parseEmailOperationConfig = (value: string): WorkflowEmailOperationConfig => { + const parsed = parseJsonObject(value) + return { + subjectTemplate: typeof parsed?.subjectTemplate === 'string' ? parsed.subjectTemplate : '', + bodyTemplate: typeof parsed?.bodyTemplate === 'string' ? parsed.bodyTemplate : '', + } +} + +const serializeEmailOperationConfig = (config: WorkflowEmailOperationConfig): string => { + if (config.subjectTemplate.trim() === '' && config.bodyTemplate.trim() === '') { + return '' + } + + return JSON.stringify({ + subjectTemplate: config.subjectTemplate, + bodyTemplate: config.bodyTemplate, + }) +} + +const parseTargetsOperationConfig = (value: string): WorkflowTargetsOperationConfig => { + const parsed = parseJsonObject(value) + return { + targets: typeof parsed?.targets === 'string' ? parsed.targets : '', + } +} + +const serializeTargetsOperationConfig = (config: WorkflowTargetsOperationConfig): string => { + const targets = config.targets + .split(',') + .map((target) => target.trim()) + .filter((target, index, items) => target !== '' && items.indexOf(target) === index) + + if (targets.length === 0) { + return '' + } + + return JSON.stringify({ + targets: targets.join(','), + }) +} + +const getWorkflowTargetLabel = (token: string): string => { + if (token === 'admin') { + return t('profile_fields', 'Administrators') + } + + if (token.startsWith('group:')) { + return token.slice(6) + } + + if (token.startsWith('user:')) { + return token.slice(5) + } + + return token +} + +const getWorkflowTargetDescription = (token: string): string => { + if (token === 'admin') { + return t('profile_fields', 'All users in the admin group') + } + + if (token.startsWith('group:')) { + return t('profile_fields', 'Group: {groupId}', { groupId: token.slice(6) }) + } + + if (token.startsWith('user:')) { + return t('profile_fields', 'User: {userId}', { userId: token.slice(5) }) + } + + return t('profile_fields', 'Custom target') +} + +const getWorkflowTargetId = (token: string): string => { + if (token === 'admin') { + return 'special-admin' + } + + if (token.startsWith('group:')) { + return `group-${token.slice(6)}` + } + + if (token.startsWith('user:')) { + return `user-${token.slice(5)}` + } + + return `custom-${token}` +} + +const toWorkflowTargetOption = ( + token: string, + label = getWorkflowTargetLabel(token), + description = getWorkflowTargetDescription(token), +): WorkflowTargetOption => { + if (token === 'admin') { + return { + id: getWorkflowTargetId(token), + displayName: label, + subname: description, + isNoUser: true, + } + } + + if (token.startsWith('group:')) { + return { + id: getWorkflowTargetId(token), + displayName: label, + subname: description, + isNoUser: true, + } + } + + if (token.startsWith('user:')) { + const userId = token.slice(5) + return { + id: getWorkflowTargetId(token), + displayName: label, + subname: description, + user: userId, + isNoUser: false, + } + } + + return { + id: getWorkflowTargetId(token), + displayName: label, + subname: description, + isNoUser: true, + } +} + +const normalizeWorkflowTargetToken = (value: string): string | null => { + const normalizedValue = value.trim().replace(/,$/, '') + if (normalizedValue === '') { + return null + } + + if (normalizedValue === 'admin') { + return normalizedValue + } + + if (/^(group|user):[^,\s]+$/i.test(normalizedValue)) { + const [scope, identifier] = normalizedValue.split(':', 2) + return `${scope.toLowerCase()}:${identifier}` + } + + return null +} + +const parseWorkflowTargetTokens = (value: string): string[] => value + .split(',') + .map((token) => token.trim()) + .filter((token, index, items) => token !== '' && items.indexOf(token) === index) + +const parseWebhookOperationConfig = (value: string): WorkflowWebhookOperationConfig => { + if (/^https?:\/\//i.test(value.trim())) { + return { + url: value.trim(), + secret: '', + timeout: '', + retries: '0', + headers: '', + } + } + + const parsed = parseJsonObject(value) + const headers = parsed?.headers !== null && typeof parsed?.headers === 'object' + ? Object.entries(parsed?.headers as Record) + .map(([name, headerValue]) => `${name}: ${String(headerValue)}`) + .join('\n') + : '' + + return { + url: typeof parsed?.url === 'string' ? parsed.url : '', + secret: typeof parsed?.secret === 'string' ? parsed.secret : '', + timeout: typeof parsed?.timeout === 'number' || typeof parsed?.timeout === 'string' ? String(parsed.timeout) : '', + retries: typeof parsed?.retries === 'number' || typeof parsed?.retries === 'string' ? String(parsed.retries) : '0', + headers, + } +} + +const serializeWebhookOperationConfig = (config: WorkflowWebhookOperationConfig): string => { + const headers = Object.fromEntries(config.headers + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.includes(':')) + .map((line) => { + const separatorIndex = line.indexOf(':') + return [line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim()] + })) + + if (config.secret.trim() === '' && config.timeout.trim() === '' && config.retries.trim() === '' && Object.keys(headers).length === 0) { + return config.url.trim() + } + + return JSON.stringify({ + url: config.url.trim(), + secret: config.secret.trim(), + timeout: config.timeout.trim() === '' ? undefined : Number(config.timeout), + retries: config.retries.trim() === '' ? 0 : Number(config.retries), + headers, + }) +} + +class WorkflowProfileFieldElement extends HTMLElement { + private modelValueInternal = '' + private operatorInternal = 'is' + private disabledInternal = false + + static get observedAttributes(): string[] { + return ['model-value', 'operator', 'disabled'] + } + + connectedCallback(): void { + this.syncFromAttributes() + void loadDefinitions().then(() => this.render()) + this.render() + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { + if (oldValue === newValue) { + return + } + + switch (name) { + case 'model-value': + this.modelValueInternal = newValue ?? '' + break + case 'operator': + this.operatorInternal = typeof newValue === 'string' && newValue !== '' ? newValue : 'is' + break + case 'disabled': + this.disabledInternal = newValue === '' || newValue === 'true' + break + default: + return + } + + this.render() + } + + set modelValue(value: string | null | undefined) { + this.modelValueInternal = typeof value === 'string' ? value : '' + this.render() + } + + get modelValue(): string { + return this.modelValueInternal + } + + set operator(value: string | null | undefined) { + this.operatorInternal = typeof value === 'string' && value !== '' ? value : 'is' + this.render() + } + + get operator(): string { + return this.operatorInternal + } + + set disabled(value: boolean | string | null | undefined) { + this.disabledInternal = value === '' || value === true || value === 'true' + this.render() + } + + get disabled(): boolean { + return this.disabledInternal + } + + private syncFromAttributes(): void { + this.modelValueInternal = this.getAttribute('model-value') ?? this.modelValueInternal + this.operatorInternal = this.getAttribute('operator') || this.operatorInternal + this.disabledInternal = this.getAttribute('disabled') === '' || this.getAttribute('disabled') === 'true' + } + + private render(): void { + const parsedValue = parseWorkflowCheckValue(this.modelValueInternal) + const selectedFieldKey = parsedValue?.field_key ?? '' + const currentValue = parsedValue?.value == null ? '' : String(parsedValue.value) + const selectedDefinition = definitions.find((definition) => definition.field_key === selectedFieldKey) ?? null + const operatorNeedsValue = workflowOperatorRequiresValue(this.operatorInternal) + const isValid = selectedDefinition !== null + && isWorkflowOperatorSupported(this.operatorInternal, this.modelValueInternal, definitions) + && (!operatorNeedsValue || currentValue.trim() !== '') + + this.replaceChildren() + + const style = document.createElement('style') + style.textContent = ` + :host { + display: flex; + flex: 1 1 22rem; + gap: .5rem; + align-items: center; + min-width: 0; + } + + select, + input { + border: 1px solid var(--color-border-maxcontrast); + border-radius: var(--border-radius-element, 6px); + background: var(--color-main-background); + color: var(--color-main-text); + font: inherit; + padding: .45rem .6rem; + min-height: 2.25rem; + } + + select { + flex: 1 1 14rem; + min-width: 10rem; + } + + input { + flex: 1 1 10rem; + min-width: 8rem; + } + + input[hidden] { + display: none; + } + + input.invalid, + select.invalid { + border-color: var(--color-error); + } + ` + + const fieldSelect = document.createElement('select') + fieldSelect.disabled = this.disabledInternal || definitions.length === 0 + fieldSelect.className = selectedDefinition === null ? 'invalid' : '' + + const placeholder = document.createElement('option') + placeholder.value = '' + placeholder.textContent = definitions.length === 0 + ? t('profile_fields', 'Loading profile fields…') + : t('profile_fields', 'Select a profile field') + fieldSelect.append(placeholder) + + for (const definition of definitions) { + const option = document.createElement('option') + option.value = definition.field_key + option.selected = definition.field_key === selectedFieldKey + option.textContent = definition.label + fieldSelect.append(option) + } + + const valueInput = document.createElement('input') + valueInput.type = selectedDefinition?.type === 'number' ? 'number' : 'text' + valueInput.value = currentValue + valueInput.disabled = this.disabledInternal || selectedDefinition === null || !operatorNeedsValue + valueInput.hidden = !operatorNeedsValue + valueInput.placeholder = selectedDefinition?.type === 'number' + ? t('profile_fields', 'Enter a numeric value') + : t('profile_fields', 'Enter a comparison value') + valueInput.className = !isValid && operatorNeedsValue ? 'invalid' : '' + + fieldSelect.addEventListener('change', () => { + const nextFieldKey = fieldSelect.value + const nextValue = serializeWorkflowCheckValue({ + field_key: nextFieldKey, + value: valueInput.value === '' ? null : valueInput.value, + }) + + dispatchModelValue(this, nextValue) + }) + + valueInput.addEventListener('input', () => { + if (fieldSelect.value === '') { + return + } + + const nextValue = serializeWorkflowCheckValue({ + field_key: fieldSelect.value, + value: valueInput.value === '' ? null : valueInput.value, + }) + + dispatchModelValue(this, nextValue) + }) + + this.append(style, fieldSelect, valueInput) + dispatchValidity(this, isValid) + } +} + +class WorkflowWebhookOperationElement extends HTMLElement { + private modelValueInternal = '' + private disabledInternal = false + + static get observedAttributes(): string[] { + return ['model-value', 'disabled'] + } + + connectedCallback(): void { + this.syncFromAttributes() + this.render() + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { + if (oldValue === newValue) { + return + } + + if (name === 'model-value') { + this.modelValueInternal = newValue ?? '' + } else if (name === 'disabled') { + this.disabledInternal = newValue === '' || newValue === 'true' + } + + this.render() + } + + set modelValue(value: string | null | undefined) { + this.modelValueInternal = typeof value === 'string' ? value : '' + this.render() + } + + get modelValue(): string { + return this.modelValueInternal + } + + set disabled(value: boolean | string | null | undefined) { + this.disabledInternal = value === '' || value === true || value === 'true' + this.render() + } + + get disabled(): boolean { + return this.disabledInternal + } + + private syncFromAttributes(): void { + this.modelValueInternal = this.getAttribute('model-value') ?? this.modelValueInternal + this.disabledInternal = this.getAttribute('disabled') === '' || this.getAttribute('disabled') === 'true' + } + + private render(): void { + const config = parseWebhookOperationConfig(this.modelValueInternal) + const isUrlValid = /^https?:\/\/.+/i.test(config.url.trim()) + const retries = Number.parseInt(config.retries || '0', 10) + const timeout = config.timeout.trim() === '' ? null : Number.parseInt(config.timeout, 10) + const isValid = isUrlValid && (Number.isNaN(retries) || retries >= 0) && (timeout === null || timeout > 0) + + this.replaceChildren() + + const style = document.createElement('style') + style.textContent = ` + :host { + display: grid; + flex: 1 1 22rem; + gap: .5rem; + min-width: 0; + } + + input, + textarea { + width: 100%; + border: 1px solid var(--color-border-maxcontrast); + border-radius: var(--border-radius-element, 6px); + background: var(--color-main-background); + color: var(--color-main-text); + font: inherit; + padding: .45rem .6rem; + min-height: 2.25rem; + } + + textarea { + min-height: 5rem; + resize: vertical; + } + + input.invalid, + textarea.invalid { + border-color: var(--color-error); + } + ` + + const urlInput = document.createElement('input') + urlInput.type = 'url' + urlInput.value = config.url + urlInput.disabled = this.disabledInternal + urlInput.placeholder = t('profile_fields', 'Enter a webhook URL') + urlInput.className = config.url === '' || isUrlValid ? '' : 'invalid' + + const secretInput = document.createElement('input') + secretInput.type = 'text' + secretInput.value = config.secret + secretInput.disabled = this.disabledInternal + secretInput.placeholder = t('profile_fields', 'Optional shared secret for HMAC signatures') + + const timeoutInput = document.createElement('input') + timeoutInput.type = 'number' + timeoutInput.min = '1' + timeoutInput.value = config.timeout + timeoutInput.disabled = this.disabledInternal + timeoutInput.placeholder = t('profile_fields', 'Timeout in seconds') + timeoutInput.className = timeout === null || timeout > 0 ? '' : 'invalid' + + const retriesInput = document.createElement('input') + retriesInput.type = 'number' + retriesInput.min = '0' + retriesInput.value = config.retries + retriesInput.disabled = this.disabledInternal + retriesInput.placeholder = t('profile_fields', 'Retry count') + retriesInput.className = Number.isNaN(retries) || retries >= 0 ? '' : 'invalid' + + const headersInput = document.createElement('textarea') + headersInput.value = config.headers + headersInput.disabled = this.disabledInternal + headersInput.placeholder = t('profile_fields', 'Optional headers, one per line, for example X-Key: value') + + const syncValue = () => { + dispatchModelValue(this, serializeWebhookOperationConfig({ + url: urlInput.value, + secret: secretInput.value, + timeout: timeoutInput.value, + retries: retriesInput.value, + headers: headersInput.value, + })) + } + + for (const input of [urlInput, secretInput, timeoutInput, retriesInput, headersInput]) { + input.addEventListener('input', syncValue) + } + + this.append(style, urlInput, secretInput, timeoutInput, retriesInput, headersInput) + dispatchValidity(this, isValid) + } +} + +class WorkflowEmailOperationElement extends HTMLElement { + private modelValueInternal = '' + private disabledInternal = false + + static get observedAttributes(): string[] { + return ['model-value', 'disabled'] + } + + connectedCallback(): void { + this.syncFromAttributes() + this.render() + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { + if (oldValue === newValue) { + return + } + + if (name === 'model-value') { + this.modelValueInternal = newValue ?? '' + } else if (name === 'disabled') { + this.disabledInternal = newValue === '' || newValue === 'true' + } + + this.render() + } + + private syncFromAttributes(): void { + this.modelValueInternal = this.getAttribute('model-value') ?? this.modelValueInternal + this.disabledInternal = this.getAttribute('disabled') === '' || this.getAttribute('disabled') === 'true' + } + + private render(): void { + const config = parseEmailOperationConfig(this.modelValueInternal) + const isValid = (config.subjectTemplate.trim() === '' && config.bodyTemplate.trim() === '') + || (config.subjectTemplate.trim() !== '' && config.bodyTemplate.trim() !== '') + + this.replaceChildren() + + const style = document.createElement('style') + style.textContent = ` + :host { + display: grid; + flex: 1 1 22rem; + gap: .5rem; + min-width: 0; + } + + input, + textarea { + width: 100%; + border: 1px solid var(--color-border-maxcontrast); + border-radius: var(--border-radius-element, 6px); + background: var(--color-main-background); + color: var(--color-main-text); + font: inherit; + padding: .45rem .6rem; + } + + textarea { + min-height: 7rem; + resize: vertical; + } + + .invalid { + border-color: var(--color-error); + } + ` + + const subjectInput = document.createElement('input') + subjectInput.type = 'text' + subjectInput.value = config.subjectTemplate + subjectInput.disabled = this.disabledInternal + subjectInput.placeholder = t('profile_fields', 'Optional email subject template') + subjectInput.className = isValid || config.subjectTemplate.trim() !== '' ? '' : 'invalid' + + const bodyInput = document.createElement('textarea') + bodyInput.value = config.bodyTemplate + bodyInput.disabled = this.disabledInternal + bodyInput.placeholder = t('profile_fields', 'Optional email body template with placeholders like {{fieldLabel}}') + bodyInput.className = isValid || config.bodyTemplate.trim() !== '' ? '' : 'invalid' + + const syncValue = () => { + dispatchModelValue(this, serializeEmailOperationConfig({ + subjectTemplate: subjectInput.value, + bodyTemplate: bodyInput.value, + })) + } + + subjectInput.addEventListener('input', syncValue) + bodyInput.addEventListener('input', syncValue) + + this.append(style, subjectInput, bodyInput) + dispatchValidity(this, isValid) + } +} + +class WorkflowTargetsOperationElement extends HTMLElement { + private modelValueInternal = '' + private disabledInternal = false + private queryInternal = '' + private suggestionsInternal: WorkflowTargetOption[] = [] + private isLoadingInternal = false + private searchTimeoutInternal: number | null = null + private searchSequenceInternal = 0 + private knownOptionsInternal = new Map() + private knownTokensByIdInternal = new Map() + private appInternal: App | null = null + private mountPointInternal: HTMLDivElement | null = null + private viewStateInternal = reactive({ + disabled: false, + loading: false, + options: [], + selected: [], + }) + + static get observedAttributes(): string[] { + return ['model-value', 'disabled'] + } + + connectedCallback(): void { + this.syncFromAttributes() + this.ensureApp() + this.syncViewState() + } + + disconnectedCallback(): void { + if (this.searchTimeoutInternal !== null) { + window.clearTimeout(this.searchTimeoutInternal) + this.searchTimeoutInternal = null + } + + this.appInternal?.unmount() + this.appInternal = null + this.mountPointInternal = null + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { + if (oldValue === newValue) { + return + } + + if (name === 'model-value') { + this.modelValueInternal = newValue ?? '' + } else if (name === 'disabled') { + this.disabledInternal = newValue === '' || newValue === 'true' + } + + this.syncViewState() + } + + private syncFromAttributes(): void { + this.modelValueInternal = this.getAttribute('model-value') ?? this.modelValueInternal + this.disabledInternal = this.getAttribute('disabled') === '' || this.getAttribute('disabled') === 'true' + } + + private get adminOption(): WorkflowTargetOption { + return toWorkflowTargetOption( + 'admin', + t('profile_fields', 'Administrators'), + t('profile_fields', 'All users in the admin group'), + ) + } + + private get selectedTargets(): string[] { + return parseWorkflowTargetTokens(parseTargetsOperationConfig(this.modelValueInternal).targets) + } + + private getTargetOption(token: string): WorkflowTargetOption { + const existingOption = this.knownOptionsInternal.get(token) + if (existingOption !== undefined) { + return existingOption + } + + return this.registerTargetOption(token, toWorkflowTargetOption(token)) + } + + private registerTargetOption(token: string, option: WorkflowTargetOption): WorkflowTargetOption { + this.knownOptionsInternal.set(token, option) + this.knownTokensByIdInternal.set(option.id, token) + return option + } + + private getTokenForOption(option: WorkflowTargetOption): string | null { + return this.knownTokensByIdInternal.get(option.id) ?? null + } + + private get visibleOptions(): WorkflowTargetOption[] { + const selectedTargets = new Set(this.selectedTargets) + + if (this.queryInternal.trim() === '') { + return selectedTargets.has('admin') ? [] : [this.adminOption] + } + + return this.suggestionsInternal.filter((option) => { + const token = this.getTokenForOption(option) + return token === null || !selectedTargets.has(token) + }) + } + + private ensureApp(): void { + if (this.appInternal !== null || !this.isConnected) { + return + } + + this.replaceChildren() + + const style = document.createElement('style') + style.textContent = ` + :host { + display: grid; + flex: 1 1 22rem; + gap: .5rem; + min-width: 0; + } + ` + + this.mountPointInternal = document.createElement('div') + this.append(style, this.mountPointInternal) + + this.appInternal = createApp({ + name: 'WorkflowTargetsOperationSelect', + render: () => h(WorkflowTargetsSelect, { + modelValue: this.viewStateInternal.selected, + disabled: this.viewStateInternal.disabled, + options: this.viewStateInternal.options, + inputLabel: t('profile_fields', 'Search admins, groups, or users'), + helperText: t('profile_fields', 'Search and select target groups or users. If left empty, administrators are used by default.'), + loading: this.viewStateInternal.loading, + onSearch: (value: string) => this.handleSearch(value), + 'onUpdate:modelValue': (value: unknown) => this.handleSelection(Array.isArray(value) + ? value as WorkflowTargetOption[] + : [value as WorkflowTargetOption]), + }), + }) + this.appInternal.mount(this.mountPointInternal) + } + + private syncViewState(): void { + this.ensureApp() + this.registerTargetOption('admin', this.adminOption) + this.viewStateInternal.disabled = this.disabledInternal + this.viewStateInternal.loading = this.isLoadingInternal + this.viewStateInternal.selected = this.selectedTargets.map((token) => this.getTargetOption(token)) + this.viewStateInternal.options = this.visibleOptions + dispatchValidity(this, true) + } + + private updateTargets(targets: string[]): void { + this.viewStateInternal.selected = targets.map((token) => this.getTargetOption(token)) + this.viewStateInternal.options = this.visibleOptions + dispatchModelValue(this, serializeTargetsOperationConfig({ targets: targets.join(',') })) + dispatchValidity(this, true) + } + + private handleSelection(options: WorkflowTargetOption[]): void { + this.queryInternal = '' + this.suggestionsInternal = [] + this.isLoadingInternal = false + + const normalizedTargets = options + .map((option) => this.getTokenForOption(option)) + .filter((token): token is string => token !== null) + .map((token) => normalizeWorkflowTargetToken(token) ?? token) + .filter((token, index, items) => items.indexOf(token) === index) + + this.updateTargets(normalizedTargets) + } + + private handleSearch(value: string): void { + this.queryInternal = value + this.viewStateInternal.options = this.visibleOptions + this.scheduleSearch() + } + + private scheduleSearch(): void { + if (this.searchTimeoutInternal !== null) { + window.clearTimeout(this.searchTimeoutInternal) + } + + this.searchTimeoutInternal = window.setTimeout(() => { + void this.loadSuggestions() + }, 150) + } + + private async loadSuggestions(): Promise { + const query = this.queryInternal.trim() + const sequence = ++this.searchSequenceInternal + + if (query === '') { + this.isLoadingInternal = false + this.suggestionsInternal = [] + this.syncViewState() + return + } + + this.isLoadingInternal = true + this.syncViewState() + try { + const suggestions = await searchWorkflowTargetSuggestions(query) + if (sequence !== this.searchSequenceInternal) { + return + } + + const optionMap = new Map() + if ('administrators'.includes(query.toLowerCase()) || 'admin'.includes(query.toLowerCase())) { + optionMap.set('admin', this.registerTargetOption('admin', this.adminOption)) + } + + for (const suggestion of suggestions) { + const option = toWorkflowTargetOption(suggestion.token, suggestion.label, suggestion.description) + optionMap.set(suggestion.token, this.registerTargetOption(suggestion.token, option)) + } + + this.suggestionsInternal = Array.from(optionMap.values()) + } catch { + if (sequence !== this.searchSequenceInternal) { + return + } + + this.suggestionsInternal = 'administrators'.includes(query.toLowerCase()) || 'admin'.includes(query.toLowerCase()) + ? [this.adminOption] + : [] + } finally { + if (sequence === this.searchSequenceInternal) { + this.isLoadingInternal = false + this.syncViewState() + } + } + } +} + +if (!window.customElements.get(workflowElementId)) { + window.customElements.define(workflowElementId, WorkflowProfileFieldElement) +} + +if (!window.customElements.get(emailOperationElementId)) { + window.customElements.define(emailOperationElementId, WorkflowEmailOperationElement) +} + +if (!window.customElements.get(targetsOperationElementId)) { + window.customElements.define(targetsOperationElementId, WorkflowTargetsOperationElement) +} + +if (!window.customElements.get(webhookOperationElementId)) { + window.customElements.define(webhookOperationElementId, WorkflowWebhookOperationElement) +} + +const buildOperators = (check: { value?: string | null }): WorkflowOperator[] => getWorkflowOperatorKeys(check.value ?? null, definitions).map((operator) => ({ + operator, + name: operatorLabels[operator] ?? operator, +})) + +const plugin: WorkflowEnginePlugin = { + class: workflowCheckClass, + name: t('profile_fields', 'Profile field value'), + operators: buildOperators, + element: workflowElementId, +} + +const operationPlugins: WorkflowEngineOperatorPlugin[] = [ + { + id: 'OCA\\ProfileFields\\Workflow\\LogProfileFieldChangeOperation', + operation: '', + color: 'var(--color-success)', + }, + { + id: 'OCA\\ProfileFields\\Workflow\\EmailUserProfileFieldChangeOperation', + operation: '', + color: 'var(--color-success)', + element: emailOperationElementId, + }, + { + id: 'OCA\\ProfileFields\\Workflow\\NotifyAdminsOrGroupsProfileFieldChangeOperation', + operation: '', + color: 'var(--color-success)', + element: targetsOperationElementId, + }, + { + id: 'OCA\\ProfileFields\\Workflow\\CreateTalkConversationProfileFieldChangeOperation', + operation: '', + color: 'var(--color-success)', + }, + { + id: 'OCA\\ProfileFields\\Workflow\\SendWebhookProfileFieldChangeOperation', + operation: '', + color: 'var(--color-success)', + element: webhookOperationElementId, + }, +] + +const ensureWorkflowCardThemeStyle = (): void => { + if (document.getElementById(workflowCardThemeStyleId) !== null) { + return + } + + const style = document.createElement('style') + style.id = workflowCardThemeStyleId + style.textContent = ` + .actions__item.${workflowItemClassName} { + color: var(--color-main-text); + } + + .actions__item.${workflowItemClassName} .actions__item__description h3, + .actions__item.${workflowItemClassName} .actions__item__description small, + .actions__item.${workflowItemClassName} .actions__item__description { + color: var(--color-main-text); + } + + .actions__item.${workflowItemClassName} .actions__item__description small { + color: color-mix(in srgb, var(--color-main-text) 78%, transparent); + } + + .actions__item.${workflowItemClassName} .icon { + background-color: currentColor; + background-image: none !important; + mask-image: var(--profile-fields-workflow-icon); + -webkit-mask-image: var(--profile-fields-workflow-icon); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-position: center; + mask-size: contain; + -webkit-mask-size: contain; + filter: none !important; + } + ` + + document.head.append(style) +} + +const isWorkflowOperationCard = (element: Element): boolean => { + const heading = element.querySelector('.actions__item__description h3') + return workflowOperationNames.has(heading?.textContent?.trim() ?? '') +} + +const applyWorkflowCardTheme = (): void => { + ensureWorkflowCardThemeStyle() + + for (const card of document.querySelectorAll('.actions__item')) { + if (!isWorkflowOperationCard(card)) { + continue + } + + card.classList.add(workflowItemClassName) + if (card.classList.contains('colored')) { + card.classList.add(workflowCardClassName) + } + + const icon = card.querySelector('.icon') + const backgroundImage = icon?.style.backgroundImage || (icon === null || icon === undefined ? '' : window.getComputedStyle(icon).backgroundImage) + if (icon !== null && backgroundImage !== '' && backgroundImage !== 'none') { + icon.style.setProperty('--profile-fields-workflow-icon', backgroundImage) + } + + const addFlowButton = card.querySelector('button') + if (card.classList.contains('colored') && addFlowButton !== null && addFlowButton.dataset.profileFieldsWorkflowTriggerBound !== 'true') { + addFlowButton.dataset.profileFieldsWorkflowTriggerBound = 'true' + addFlowButton.addEventListener('click', () => { + window.setTimeout(() => { + const store = getWorkflowStore() + if (store !== null) { + applyDefaultTriggerToNewestWorkflowRule(store) + } + }, 0) + }) + } + } +} + +let workflowCardThemeObserver: MutationObserver | null = null +let workflowDefaultsPatchAttempts = 0 +let workflowDefaultsPatched = false + +const observeWorkflowCards = (): void => { + applyWorkflowCardTheme() + + if (workflowCardThemeObserver !== null || document.body === null) { + return + } + + workflowCardThemeObserver = new MutationObserver(() => applyWorkflowCardTheme()) + workflowCardThemeObserver.observe(document.body, { + childList: true, + subtree: true, + }) +} + +const startWorkflowCardTheme = (): void => { + applyWorkflowCardTheme() + + if (document.body !== null) { + observeWorkflowCards() + return + } + + document.addEventListener('DOMContentLoaded', () => observeWorkflowCards(), { once: true }) + window.setTimeout(() => observeWorkflowCards(), 0) +} + +const getWorkflowRootVm = (): WorkflowEngineRootVm | null => { + const root = document.querySelector('#workflowengine') as (HTMLElement & { __vue__?: WorkflowEngineRootVm }) | null + return root?.__vue__ ?? null +} + +const getWorkflowStore = (): WorkflowEngineStore | null => { + return getWorkflowRootVm()?.$store ?? null +} + +const getDefaultWorkflowEventName = (store: WorkflowEngineStore): string | null => { + const entity = store.state.entities.find((item) => item.id === workflowEntityClass) + if (entity === undefined) { + return null + } + + return entity.events.find((event) => event.eventName === workflowUpdatedEventClass)?.eventName + ?? entity.events[0]?.eventName + ?? null +} + +const applyDefaultTriggerToNewestWorkflowRule = (store: WorkflowEngineStore): void => { + const defaultEventName = getDefaultWorkflowEventName(store) + if (defaultEventName === null) { + return + } + + const targetRule = [...store.state.rules] + .reverse() + .find((rule) => workflowOperationClasses.includes(rule.class) && rule.id < 0) + + if (targetRule === undefined) { + return + } + + if (targetRule.entity === workflowEntityClass && targetRule.events.length === 1 && targetRule.events[0] === defaultEventName) { + return + } + + store.commit('updateRule', { + ...targetRule, + entity: workflowEntityClass, + events: [defaultEventName], + }) +} + +const patchWorkflowCreateRuleDefaults = (): void => { + if (workflowDefaultsPatched) { + return + } + + const store = getWorkflowStore() + const rootVm = getWorkflowRootVm() + if (store === null || rootVm === null) { + if (workflowDefaultsPatchAttempts >= 20) { + return + } + + workflowDefaultsPatchAttempts += 1 + window.setTimeout(patchWorkflowCreateRuleDefaults, 50) + return + } + + workflowDefaultsPatched = true +} + +void loadDefinitions() +startWorkflowCardTheme() +patchWorkflowCreateRuleDefaults() + +let registrationAttempts = 0 + +const getWorkflowEngineApi = (): WorkflowEngineApi | null => { + const workflowEngine = (window as Window & { OCA?: { WorkflowEngine?: WorkflowEngineApi } }).OCA?.WorkflowEngine + if (!workflowEngine || typeof workflowEngine.registerCheck !== 'function' || typeof workflowEngine.registerOperator !== 'function') { + return null + } + + return workflowEngine +} + +const registerWorkflowPlugins = (): void => { + const workflowEngine = getWorkflowEngineApi() + if (workflowEngine !== null) { + workflowEngine.registerCheck(plugin) + for (const operationPlugin of operationPlugins) { + workflowEngine.registerOperator(operationPlugin) + } + return + } + + if (registrationAttempts >= 20) { + return + } + + registrationAttempts += 1 + window.setTimeout(registerWorkflowPlugins, 50) +} + +registerWorkflowPlugins() diff --git a/tests/integration/composer.json b/tests/integration/composer.json index 4723aa0..923620a 100644 --- a/tests/integration/composer.json +++ b/tests/integration/composer.json @@ -1,8 +1,10 @@ { "require": { "behat/behat": "^3.13", + "donatj/mock-webserver": "^2.10", "guzzlehttp/guzzle": "^7.10", "jarnaiz/behat-junit-formatter": "^1.3", + "libresign/mailpit-behat-extension": "^0.1.1", "libresign/nextcloud-behat": "^1.4", "php-http/guzzle7-adapter": "^1.1", "php-http/message": "^1.16" diff --git a/tests/integration/composer.lock b/tests/integration/composer.lock index e0cf0e9..88e13de 100644 --- a/tests/integration/composer.lock +++ b/tests/integration/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b027acd99de58db640f255fa2cd7f37d", + "content-hash": "a49ee400de7c9024859ac248ed48766f", "packages": [ { "name": "behat/behat", @@ -338,6 +338,83 @@ ], "time": "2024-11-12T16:29:46+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "composer/xdebug-handler", "version": "3.0.5", @@ -404,6 +481,82 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "donatj/mock-webserver", + "version": "v2.10.0", + "source": { + "type": "git", + "url": "https://github.com/donatj/mock-webserver.git", + "reference": "5074522d25f4e83953b1c5417a884c995d0dfa91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/donatj/mock-webserver/zipball/5074522d25f4e83953b1c5417a884c995d0dfa91", + "reference": "5074522d25f4e83953b1c5417a884c995d0dfa91", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-sockets": "*", + "php": ">=7.2", + "ralouphie/getallheaders": "~2.0 || ~3.0" + }, + "require-dev": { + "corpus/coding-standard": "^0.6.0 || ^0.9.0", + "donatj/drop": "^1.0", + "ext-curl": "*", + "friendsofphp/php-cs-fixer": "^3.1", + "phpunit/phpunit": "~8|~9", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "donatj\\MockWebServer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jesse G. Donat", + "email": "donatj@gmail.com", + "homepage": "https://donatstudios.com", + "role": "Lead" + } + ], + "description": "Simple mock web server for unit testing", + "keywords": [ + "dev", + "http", + "mock", + "phpunit", + "testing", + "unit testing", + "webserver" + ], + "support": { + "issues": "https://github.com/donatj/mock-webserver/issues", + "source": "https://github.com/donatj/mock-webserver/tree/v2.10.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/donatj/15", + "type": "custom" + }, + { + "url": "https://github.com/donatj", + "type": "github" + }, + { + "url": "https://ko-fi.com/donatj", + "type": "ko_fi" + } + ], + "time": "2026-02-20T18:53:16+00:00" + }, { "name": "estahn/json-query-wrapper", "version": "v1.0.0", @@ -870,18 +1023,141 @@ }, "time": "2024-10-31T18:50:11+00:00" }, + { + "name": "libresign/mailpit-behat-extension", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/LibreSign/mailpit-behat-extension.git", + "reference": "5867dc381dc2c04dc239341267d9aeff977edca7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/LibreSign/mailpit-behat-extension/zipball/5867dc381dc2c04dc239341267d9aeff977edca7", + "reference": "5867dc381dc2c04dc239341267d9aeff977edca7", + "shasum": "" + }, + "require": { + "libresign/mailpit-client": "^0.1", + "nyholm/psr7": "^1.3", + "php": "^8.2", + "psr-discovery/http-client-implementations": "^1.0", + "psr-discovery/http-factory-implementations": "^1.0", + "symfony/dependency-injection": "^7.0", + "symfony/http-client": "^7.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "behat/behat": "^3.8.0", + "roave/security-advisories": "dev-master", + "symfony/dotenv": "^7.0", + "symfony/mailer": "^7.0", + "symfony/mime": "^7.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true + } + }, + "autoload": { + "psr-4": { + "LibreSign\\Behat\\MailpitExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "LibreSign Team", + "email": "hello@libresign.coop" + }, + { + "name": "Remon van de Kamp", + "email": "rpkamp@gmail.com", + "role": "Original Mailhog Behat extension author" + } + ], + "description": "Mailpit Extension for Behat - Modern replacement for Mailhog", + "support": { + "source": "https://github.com/LibreSign/mailpit-behat-extension/tree/v0.1.1" + }, + "time": "2026-02-03T22:20:24+00:00" + }, + { + "name": "libresign/mailpit-client", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/LibreSign/mailpit-client.git", + "reference": "382507d3e1029a9048e1320bf67991783e34d0e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/LibreSign/mailpit-client/zipball/382507d3e1029a9048e1320bf67991783e34d0e2", + "reference": "382507d3e1029a9048e1320bf67991783e34d0e2", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^8.2", + "php-http/httplug": "^2.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "roave/security-advisories": "dev-master" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true + } + }, + "autoload": { + "psr-4": { + "LibreSign\\Mailpit\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "LibreSign Team", + "email": "hello@libresign.coop" + }, + { + "name": "Remon van de Kamp", + "email": "rpkamp@gmail.com", + "role": "Original Mailhog client author" + } + ], + "description": "Mailpit API Client for PHP - Modern replacement for Mailhog", + "support": { + "source": "https://github.com/LibreSign/mailpit-client/tree/v0.1.1" + }, + "time": "2026-02-03T19:14:09+00:00" + }, { "name": "libresign/nextcloud-behat", - "version": "v1.4.2", + "version": "v1.5.0", "source": { "type": "git", "url": "https://github.com/LibreSign/nextcloud-behat.git", - "reference": "7876b05d451d751e448a5560aad89a9cb224b710" + "reference": "5a2c0e41db13769a06e58fc872debc424b9e5209" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/LibreSign/nextcloud-behat/zipball/7876b05d451d751e448a5560aad89a9cb224b710", - "reference": "7876b05d451d751e448a5560aad89a9cb224b710", + "url": "https://api.github.com/repos/LibreSign/nextcloud-behat/zipball/5a2c0e41db13769a06e58fc872debc424b9e5209", + "reference": "5a2c0e41db13769a06e58fc872debc424b9e5209", "shasum": "" }, "require": { @@ -926,9 +1202,9 @@ ], "support": { "issues": "https://github.com/LibreSign/nextcloud-behat/issues", - "source": "https://github.com/LibreSign/nextcloud-behat/tree/v1.4.2" + "source": "https://github.com/LibreSign/nextcloud-behat/tree/v1.5.0" }, - "time": "2026-02-16T21:51:37+00:00" + "time": "2026-03-17T16:37:29+00:00" }, { "name": "myclabs/deep-copy", @@ -1048,6 +1324,84 @@ }, "time": "2025-12-06T11:56:16+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "phar-io/manifest", "version": "2.0.4", @@ -1815,50 +2169,273 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "psr-discovery/discovery", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/psr-discovery/discovery.git", + "reference": "636f67406eadd33a66a7e65b9f0e26abfd7614ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/psr-discovery/discovery/zipball/636f67406eadd33a66a7e65b9f0e26abfd7614ac", + "reference": "636f67406eadd33a66a7e65b9f0e26abfd7614ac", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "php": "^8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.0", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-strict-rules": "^1.5", + "rector/rector": "^0.15", + "vimeo/psalm": "^5.8", + "wikimedia/composer-merge-plugin": "^2.0" + }, + "type": "library", + "extra": { + "merge-plugin": { + "include": [ + "composer.local.json" + ], + "recurse": true, + "replace": true, + "merge-dev": true, + "merge-extra": false, + "merge-scripts": false, + "merge-extra-deep": false, + "ignore-duplicates": false + } + }, + "autoload": { + "psr-4": { + "PsrDiscovery\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Evan Sims", + "email": "hello@evansims.com", + "homepage": "https://evansims.com/" + } + ], + "description": "Lightweight library that discovers available PSR implementations by searching for a list of well-known classes that implement the relevant interfaces, and returning an instance of the first one that is found.", + "homepage": "https://github.com/psr-discovery/discovery", + "keywords": [ + "PSR-11", + "discovery", + "psr", + "psr-14", + "psr-17", + "psr-18", + "psr-3", + "psr-6" + ], + "support": { + "issues": "https://github.com/psr-discovery/discovery/issues", + "source": "https://github.com/psr-discovery/discovery/tree/1.2.0" + }, + "time": "2024-12-05T16:59:22+00:00" + }, + { + "name": "psr-discovery/http-client-implementations", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/psr-discovery/http-client-implementations.git", + "reference": "c104e0fc5b6204a22cdacb6e41c73065fadf0ad4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/psr-discovery/http-client-implementations/zipball/c104e0fc5b6204a22cdacb6e41c73065fadf0ad4", + "reference": "c104e0fc5b6204a22cdacb6e41c73065fadf0ad4", + "shasum": "" + }, + "require": { + "php": "^8.2", + "psr-discovery/discovery": "^1.0", + "psr/http-client": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.0", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-strict-rules": "^1.5", + "rector/rector": "^0.15", + "vimeo/psalm": "^5.8", + "wikimedia/composer-merge-plugin": "^2.0" + }, + "type": "library", + "extra": { + "merge-plugin": { + "include": [ + "composer.local.json" + ], + "recurse": true, + "replace": true, + "merge-dev": true, + "merge-extra": false, + "merge-scripts": false, + "merge-extra-deep": false, + "ignore-duplicates": false + } + }, + "autoload": { + "psr-4": { + "PsrDiscovery\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Evan Sims", + "email": "hello@evansims.com", + "homepage": "https://evansims.com/" + } + ], + "description": "Lightweight library that discovers available PSR-18 HTTP Client implementations by searching for a list of well-known classes that implement the relevant interface, and returns an instance of the first one that is found.", + "homepage": "https://github.com/psr-discovery/http-client-implementations", + "keywords": [ + "discovery", + "psr", + "psr-18" + ], + "support": { + "issues": "https://github.com/psr-discovery/http-client-implementations/issues", + "source": "https://github.com/psr-discovery/http-client-implementations/tree/v1.5.0" + }, + "time": "2026-03-09T20:38:48+00:00" + }, + { + "name": "psr-discovery/http-factory-implementations", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/psr-discovery/http-factory-implementations.git", + "reference": "3979e3d9a95bedd91c13e1de12c6e99a74c449d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/psr-discovery/http-factory-implementations/zipball/3979e3d9a95bedd91c13e1de12c6e99a74c449d3", + "reference": "3979e3d9a95bedd91c13e1de12c6e99a74c449d3", + "shasum": "" + }, + "require": { + "php": "^8.2", + "psr-discovery/discovery": "^1.1", + "psr/http-factory": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.0", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-strict-rules": "^1.5", + "rector/rector": "^0.15", + "vimeo/psalm": "^5.8", + "wikimedia/composer-merge-plugin": "^2.0" + }, + "type": "library", + "extra": { + "merge-plugin": { + "include": [ + "composer.local.json" + ], + "recurse": true, + "replace": true, + "merge-dev": true, + "merge-extra": false, + "merge-scripts": false, + "merge-extra-deep": false, + "ignore-duplicates": false + } + }, + "autoload": { + "psr-4": { + "PsrDiscovery\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Evan Sims", + "email": "hello@evansims.com", + "homepage": "https://evansims.com/" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", + "description": "Lightweight library that discovers available PSR-17 HTTP Factory implementations by searching for a list of well-known classes that implement the relevant interface, and returns an instance of the first one that is found.", + "homepage": "https://github.com/psr-discovery/http-factory-implementations", "keywords": [ - "phpunit", - "testing", - "xunit" + "discovery", + "psr", + "psr-18" ], "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "issues": "https://github.com/psr-discovery/http-factory-implementations/issues", + "source": "https://github.com/psr-discovery/http-factory-implementations/tree/1.2.0" }, - "funding": [ - { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" - } - ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2024-12-05T17:18:21+00:00" }, { "name": "psr/container", @@ -3814,6 +4391,185 @@ ], "time": "2026-02-25T16:50:00+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", + "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-05T11:16:58+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.33.0", @@ -4233,6 +4989,86 @@ ], "time": "2025-01-02T08:10:11+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, { "name": "symfony/process", "version": "v5.4.51", diff --git a/tests/integration/config/behat.yml b/tests/integration/config/behat.yml index f85c267..a6fd55e 100644 --- a/tests/integration/config/behat.yml +++ b/tests/integration/config/behat.yml @@ -8,12 +8,14 @@ default: suites: default: contexts: - - FeatureContext - - Libresign\NextcloudBehat\NextcloudApiContext + - MockWebServerContext + - LibreSign\Behat\MailpitExtension\Context\MailpitContext paths: - '%paths.base%/../features' extensions: + LibreSign\Behat\MailpitExtension\ServiceContainer\MailpitExtension: + base_url: http://mailpit:8025 PhpBuiltin\Server: workers: 10 jarnaiz\JUnitFormatter\JUnitFormatterExtension: diff --git a/tests/integration/features/api/profile_fields.feature b/tests/integration/features/api/profile_fields.feature index d8dfd76..f2cbd3f 100644 --- a/tests/integration/features/api/profile_fields.feature +++ b/tests/integration/features/api/profile_fields.feature @@ -1,6 +1,7 @@ Feature: profile fields API Background: Given user "profileuser" exists + And run the command "profile_fields:developer:reset --all" with result code 0 Scenario: unauthenticated users cannot list their own fields Given as user "" @@ -150,7 +151,6 @@ Feature: profile fields API | (jq).ocs.data[] \| select(.field_definition_id == ) \| .value.value | Alpha | | (jq).ocs.data[] \| select(.field_definition_id == ) \| .current_visibility | public | | (jq).ocs.data[] \| select(.field_definition_id == ) \| .value.value | EMP-001 | - Scenario: payroll ETL can resolve a cooperado by cpf and read the other payment fields Given as user "admin" When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php deleted file mode 100644 index 7a253b2..0000000 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ /dev/null @@ -1,31 +0,0 @@ -/occ ' . $command, 0); - } -} diff --git a/tests/integration/features/bootstrap/MockWebServerContext.php b/tests/integration/features/bootstrap/MockWebServerContext.php new file mode 100644 index 0000000..f24f8b9 --- /dev/null +++ b/tests/integration/features/bootstrap/MockWebServerContext.php @@ -0,0 +1,137 @@ + */ + private array $mockServers = []; + + /** + * @Given /^the mock web server "([^"]*)" is started$/ + */ + public function theMockWebServerIsStarted(string $serverName): void { + if (isset($this->mockServers[$serverName]) && $this->mockServers[$serverName]->isRunning()) { + return; + } + + $server = new MockWebServer(); + $server->start(); + $this->mockServers[$serverName] = $server; + } + + /** + * @Given /^save the mock web server "([^"]*)" root URL as "([^"]*)"$/ + */ + public function saveTheMockWebServerRootUrlAs(string $serverName, string $fieldName): void { + $this->fields[$fieldName] = $this->getMockServer($serverName)->getServerRoot(); + } + + /** + * @When /^read the last request from mock web server "([^"]*)"$/ + */ + public function readTheLastRequestFromMockWebServer(string $serverName): void { + $request = $this->getLastRequest($serverName); + self::$commandOutput = json_encode($request, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + /** + * @When /^read the last request body from mock web server "([^"]*)"$/ + */ + public function readTheLastRequestBodyFromMockWebServer(string $serverName): void { + $input = $this->getLastRequest($serverName)->getInput(); + + try { + $decoded = json_decode($input, true, 512, JSON_THROW_ON_ERROR); + self::$commandOutput = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } catch (JsonException) { + self::$commandOutput = $input; + } + } + + /** + * @When /^read the last Nextcloud log entry containing "([^"]*)"$/ + */ + public function readTheLastNextcloudLogEntryContaining(string $needle): void { + $logPath = static::findParentDirContainingFile('console.php') . '/data/nextcloud.log'; + + $deadline = microtime(true) + 4; + do { + $entry = $this->findLastLogEntryContaining($logPath, $needle); + if ($entry !== null) { + self::$commandOutput = $entry; + return; + } + + usleep(200000); + } while (microtime(true) < $deadline); + + throw new RuntimeException('Nextcloud log does not contain: ' . $needle); + } + + #[AfterScenario()] + public function stopMockWebServers(): void { + foreach ($this->mockServers as $server) { + if ($server->isRunning()) { + $server->stop(); + } + } + + $this->mockServers = []; + } + + protected function beforeRequest(string $fullUrl, array $options): array { + [$fullUrl, $options] = parent::beforeRequest($fullUrl, $options); + + if (isset($options['body']) && is_string($options['body'])) { + $options['body'] = $this->parseText($options['body']); + } + + return [$fullUrl, $options]; + } + + private function getMockServer(string $serverName): MockWebServer { + if (!isset($this->mockServers[$serverName])) { + throw new RuntimeException('Mock web server "' . $serverName . '" is not started'); + } + + return $this->mockServers[$serverName]; + } + + private function getLastRequest(string $serverName): RequestInfo { + $request = $this->getMockServer($serverName)->getLastRequest(); + if (!$request instanceof RequestInfo) { + throw new RuntimeException('Mock web server "' . $serverName . '" has not received any request yet'); + } + + return $request; + } + + private function findLastLogEntryContaining(string $logPath, string $needle): ?string { + if (!is_file($logPath)) { + throw new RuntimeException('Nextcloud log file not found at ' . $logPath); + } + + $lines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if (!is_array($lines)) { + throw new RuntimeException('Unable to read Nextcloud log file at ' . $logPath); + } + + for ($index = count($lines) - 1; $index >= 0; $index--) { + if (str_contains($lines[$index], $needle)) { + return $lines[$index]; + } + } + + return null; + } +} diff --git a/tests/integration/features/workflow/profile_fields_workflow.feature b/tests/integration/features/workflow/profile_fields_workflow.feature new file mode 100644 index 0000000..0445c7a --- /dev/null +++ b/tests/integration/features/workflow/profile_fields_workflow.feature @@ -0,0 +1,330 @@ +Feature: profile field workflows + Background: + Given user "workflow_subject" exists + And run the command "app:enable --force profile_fields" with result code 0 + And run the command "config:system:set allow_local_remote_servers --value true --type boolean" with result code 0 + And run the command "profile_fields:developer:reset --all" with result code 0 + + Scenario: matching field updates notify configured admin targets through workflow and notifications OCS + Given as user "admin" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | workflow_notify_department | + | label | Workflow Notify Department | + | type | text | + | adminOnly | false | + | userEditable | true | + | userVisible | true | + | initialVisibility | users | + | sortOrder | 10 | + | active | true | + Then the response should have a status code 201 + And fetch field "(WORKFLOW_NOTIFY_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "put" to ocs "/apps/profile_fields/api/v1/definitions/" + | label | Workflow Notify Department | + | type | text | + | adminOnly | false | + | userEditable | true | + | userVisible | true | + | initialVisibility | users | + | sortOrder | 10 | + | active | true | + Then the response should have a status code 200 + When sending "post" to ocs "/apps/workflowengine/api/v1/workflows/global" + """ + { + "class": "OCA\\ProfileFields\\Workflow\\NotifyAdminsOrGroupsProfileFieldChangeOperation", + "name": "workflow notify admins", + "checks": [ + { + "class": "OCA\\ProfileFields\\Workflow\\UserProfileFieldCheck", + "operator": "is", + "value": "{\"field_key\":\"workflow_notify_department\",\"value\":\"engineering\"}" + } + ], + "operation": "{\"targets\":\"user:admin\"}", + "entity": "OCA\\ProfileFields\\Workflow\\ProfileFieldValueEntity", + "events": [ + "OCA\\ProfileFields\\Workflow\\Event\\ProfileFieldValueUpdatedEvent" + ] + } + """ + Then the response should have a status code 200 + Given as user "workflow_subject" + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | finance | + | currentVisibility | users | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | engineering | + | currentVisibility | users | + Then the response should have a status code 200 + Given as user "admin" + When sending "get" to ocs "/apps/notifications/api/v2/notifications" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data[] \| select(.app == "profile_fields" and .user == "admin" and .object_type == "profile-field-admin-change" and .subject == "Profile field updated") \| .app | profile_fields | + | (jq).ocs.data[] \| select(.app == "profile_fields" and .user == "admin" and .object_type == "profile-field-admin-change" and .subject == "Profile field updated") \| .message | workflow_subject changed workflow_subject's Workflow Notify Department profile field. | + + Scenario: matching field updates write workflow log entries + Given as user "admin" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | workflow_log_department | + | label | Workflow Log Department | + | type | text | + | adminOnly | false | + | userEditable | true | + | userVisible | true | + | initialVisibility | users | + | sortOrder | 20 | + | active | true | + Then the response should have a status code 201 + And fetch field "(WORKFLOW_LOG_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "post" to ocs "/apps/workflowengine/api/v1/workflows/global" + """ + { + "class": "OCA\\ProfileFields\\Workflow\\LogProfileFieldChangeOperation", + "name": "workflow write log", + "checks": [ + { + "class": "OCA\\ProfileFields\\Workflow\\UserProfileFieldCheck", + "operator": "is", + "value": "{\"field_key\":\"workflow_log_department\",\"value\":\"operations\"}" + } + ], + "operation": "", + "entity": "OCA\\ProfileFields\\Workflow\\ProfileFieldValueEntity", + "events": [ + "OCA\\ProfileFields\\Workflow\\Event\\ProfileFieldValueUpdatedEvent" + ] + } + """ + Then the response should have a status code 200 + Given as user "workflow_subject" + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | finance | + | currentVisibility | users | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | operations | + | currentVisibility | users | + Then the response should have a status code 200 + When read the last Nextcloud log entry containing "Profile field workflow rule matched" + Then the output of the last command should contain the following text: + """ + Profile field workflow rule matched + """ + And the output of the last command should contain the following text: + """ + workflow_log_department + """ + And the output of the last command should contain the following text: + """ + workflow_subject + """ + And the output of the last command should contain the following text: + """ + operations + """ + + Scenario: matching field updates send workflow webhook payloads + Given as user "admin" + And the mock web server "webhook-capture" is started + And save the mock web server "webhook-capture" root URL as "WEBHOOK_CAPTURE_URL" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | workflow_webhook_department | + | label | Workflow Webhook Department | + | type | text | + | adminOnly | false | + | userEditable | true | + | userVisible | true | + | initialVisibility | users | + | sortOrder | 30 | + | active | true | + Then the response should have a status code 201 + And fetch field "(WORKFLOW_WEBHOOK_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "post" to ocs "/apps/workflowengine/api/v1/workflows/global" + """ + { + "class": "OCA\\ProfileFields\\Workflow\\SendWebhookProfileFieldChangeOperation", + "name": "workflow send webhook", + "checks": [ + { + "class": "OCA\\ProfileFields\\Workflow\\UserProfileFieldCheck", + "operator": "is", + "value": "{\"field_key\":\"workflow_webhook_department\",\"value\":\"security\"}" + } + ], + "operation": "{\"url\":\"/profile-fields\",\"secret\":\"shared-secret\",\"timeout\":10,\"retries\":0,\"headers\":{\"X-Test-Suite\":\"profile_fields\"}}", + "entity": "OCA\\ProfileFields\\Workflow\\ProfileFieldValueEntity", + "events": [ + "OCA\\ProfileFields\\Workflow\\Event\\ProfileFieldValueUpdatedEvent" + ] + } + """ + Then the response should have a status code 200 + Given as user "workflow_subject" + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | finance | + | currentVisibility | users | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | security | + | currentVisibility | users | + Then the response should have a status code 200 + When read the last request from mock web server "webhook-capture" + Then the output of the last command should contain the following text: + """ + "METHOD": "POST" + """ + And the output of the last command should contain the following text: + """ + "REQUEST_URI": "/profile-fields" + """ + And the output of the last command should contain the following text: + """ + "X-Test-Suite": "profile_fields" + """ + And the output of the last command should contain the following text: + """ + "X-Profile-Fields-Signature": "sha256= + """ + When read the last request body from mock web server "webhook-capture" + And the output of the last command should contain the following text: + """ + "name": "workflow send webhook" + """ + And the output of the last command should contain the following text: + """ + "key": "workflow_webhook_department" + """ + And the output of the last command should contain the following text: + """ + "previousValue": "finance" + """ + And the output of the last command should contain the following text: + """ + "currentValue": "security" + """ + + Scenario: matching field updates email the affected user + Given as user "admin" + And run the command "config:system:set mail_smtpmode --value smtp" with result code 0 + And run the command "config:system:set mail_smtphost --value mailpit" with result code 0 + And run the command "config:system:set mail_smtpport --value 1025 --type integer" with result code 0 + And run the command "config:system:set mail_smtpauth --value false --type boolean" with result code 0 + And run the command "config:system:set mail_smtpsecure --value \"\"" with result code 0 + And run the command "config:system:set mail_from_address --value profile-fields" with result code 0 + And run the command "config:system:set mail_domain --value example.test" with result code 0 + And set the email of user "workflow_subject" to "workflow_subject@example.test" + And my inbox is empty + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | workflow_email_department | + | label | Workflow Email Department | + | type | text | + | adminOnly | false | + | userEditable | true | + | userVisible | true | + | initialVisibility | users | + | sortOrder | 40 | + | active | true | + Then the response should have a status code 201 + And fetch field "(WORKFLOW_EMAIL_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "post" to ocs "/apps/workflowengine/api/v1/workflows/global" + """ + { + "class": "OCA\\ProfileFields\\Workflow\\EmailUserProfileFieldChangeOperation", + "name": "workflow email user", + "checks": [ + { + "class": "OCA\\ProfileFields\\Workflow\\UserProfileFieldCheck", + "operator": "is", + "value": "{\"field_key\":\"workflow_email_department\",\"value\":\"compliance\"}" + } + ], + "operation": "{\"subjectTemplate\":\"Profile update: {{fieldLabel}}\",\"bodyTemplate\":\"Field {{fieldLabel}} changed from {{previousValue}} to {{currentValue}} by {{actorUid}}.\"}", + "entity": "OCA\\ProfileFields\\Workflow\\ProfileFieldValueEntity", + "events": [ + "OCA\\ProfileFields\\Workflow\\Event\\ProfileFieldValueUpdatedEvent" + ] + } + """ + Then the response should have a status code 200 + Given as user "workflow_subject" + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | finance | + | currentVisibility | users | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | compliance | + | currentVisibility | users | + Then the response should have a status code 200 + And there should be 1 email in my inbox + When I open the latest email to "workflow_subject@example.test" with subject "Profile update: Workflow Email Department" + Then I should see "Field Workflow Email Department changed from finance to compliance by workflow_subject." in the opened email + + Scenario: matching field updates create Talk conversations for admins and affected users + Given as user "admin" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | workflow_talk_department | + | label | Workflow Talk Department | + | type | text | + | adminOnly | false | + | userEditable | true | + | userVisible | true | + | initialVisibility | users | + | sortOrder | 50 | + | active | true | + Then the response should have a status code 201 + And fetch field "(WORKFLOW_TALK_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "put" to ocs "/apps/profile_fields/api/v1/definitions/" + | label | Workflow Talk Department | + | type | text | + | adminOnly | false | + | userEditable | true | + | userVisible | true | + | initialVisibility | users | + | sortOrder | 50 | + | active | true | + Then the response should have a status code 200 + When sending "post" to ocs "/apps/workflowengine/api/v1/workflows/global" + """ + { + "class": "OCA\\ProfileFields\\Workflow\\CreateTalkConversationProfileFieldChangeOperation", + "name": "workflow create talk conversation", + "checks": [ + { + "class": "OCA\\ProfileFields\\Workflow\\UserProfileFieldCheck", + "operator": "is", + "value": "{\"field_key\":\"workflow_talk_department\",\"value\":\"support\"}" + } + ], + "operation": "", + "entity": "OCA\\ProfileFields\\Workflow\\ProfileFieldValueEntity", + "events": [ + "OCA\\ProfileFields\\Workflow\\Event\\ProfileFieldValueUpdatedEvent" + ] + } + """ + Then the response should have a status code 200 + Given as user "workflow_subject" + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | finance | + | currentVisibility | users | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/me/values/" + | value | support | + | currentVisibility | users | + Then the response should have a status code 200 + Given as user "admin" + When sending "get" to ocs "/apps/spreed/api/v4/room" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data[] \| select(.displayName == "Profile field change: Workflow Talk Department for workflow_subject") \| .displayName | Profile field change: Workflow Talk Department for workflow_subject | + Given as user "workflow_subject" + When sending "get" to ocs "/apps/spreed/api/v4/room" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data[] \| select(.displayName == "Profile field change: Workflow Talk Department for workflow_subject") \| .displayName | Profile field change: Workflow Talk Department for workflow_subject | diff --git a/tests/php/ControllerIntegration/Workflow/LogProfileFieldChangeOperationTest.php b/tests/php/ControllerIntegration/Workflow/LogProfileFieldChangeOperationTest.php new file mode 100644 index 0000000..0d2e919 --- /dev/null +++ b/tests/php/ControllerIntegration/Workflow/LogProfileFieldChangeOperationTest.php @@ -0,0 +1,336 @@ +> */ + private array $listeners = []; + + public function addListener(string $eventName, callable $listener, int $priority = 0): void { + $this->listeners[$eventName] ??= []; + $this->listeners[$eventName][] = $listener; + } + + public function removeListener(string $eventName, callable $listener): void { + if (!isset($this->listeners[$eventName])) { + return; + } + + $this->listeners[$eventName] = array_values(array_filter( + $this->listeners[$eventName], + static fn (callable $registered): bool => $registered !== $listener, + )); + } + + public function addServiceListener(string $eventName, string $className, int $priority = 0): void { + } + + public function hasListeners(string $eventName): bool { + return isset($this->listeners[$eventName]) && $this->listeners[$eventName] !== []; + } + + public function dispatch(string $eventName, Event $event): void { + foreach ($this->listeners[$eventName] ?? [] as $listener) { + $listener($event); + } + } + + public function dispatchTyped(Event $event): void { + $this->dispatch($event::class, $event); + } +} + +/** + * @group DB + */ +class LogProfileFieldChangeOperationTest extends TestCase { + private const FIELD_KEY = 'department_workflow_operation_integration'; + + private FieldDefinition $definition; + private FieldDefinitionMapper $fieldDefinitionMapper; + private FieldValueMapper $fieldValueMapper; + private FieldDefinitionService $fieldDefinitionService; + private IDBConnection $connection; + private IUserManager $userManager; + private IUserSession&MockObject $userSession; + private WorkflowTestEventDispatcher $dispatcher; + private object $workflowManager; + private LoggerInterface&MockObject $operationLogger; + /** @var array */ + private array $createdUserIds = []; + + protected function setUp(): void { + parent::setUp(); + + $app = new \OCP\AppFramework\App(ProfileFieldsApplication::APP_ID); + $appContainer = $app->getContainer(); + + $this->fieldDefinitionMapper = $appContainer->get(FieldDefinitionMapper::class); + $this->fieldValueMapper = $appContainer->get(FieldValueMapper::class); + $this->fieldDefinitionService = $appContainer->get(FieldDefinitionService::class); + $this->connection = $appContainer->get(IDBConnection::class); + $this->userManager = $appContainer->get(IUserManager::class); + + self::ensureSchemaExists($this->connection); + $this->clearWorkflowTables(); + $this->deleteDefinitionIfExists(self::FIELD_KEY); + $this->definition = $this->createDefinition(self::FIELD_KEY); + + $this->dispatcher = new WorkflowTestEventDispatcher(); + $this->userSession = $this->createMock(IUserSession::class); + $this->userSession->method('getUser')->willReturn(null); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t') + ->willReturnCallback(static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters)); + + $generalLogger = $this->createMock(LoggerInterface::class); + $workflowLoggerClass = 'OCA\\WorkflowEngine\\Service\\Logger'; + $flowLogger = $this->createMock($workflowLoggerClass); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueBool')->willReturn(false); + + $cache = $this->createMock(ICache::class); + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($cache); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('imagePath')->willReturn('/core/img/actions/profile.svg'); + + $subjectContext = new ProfileFieldValueSubjectContext(); + $fieldValueService = new FieldValueService($this->fieldValueMapper, $this->dispatcher); + $check = new UserProfileFieldCheck( + $this->userSession, + $l10n, + $this->fieldDefinitionService, + $fieldValueService, + $subjectContext, + ); + + $this->operationLogger = $this->createMock(LoggerInterface::class); + $entity = new ProfileFieldValueEntity($l10n, $urlGenerator, $subjectContext); + $operation = new LogProfileFieldChangeOperation($this->operationLogger, $l10n, $urlGenerator, $subjectContext); + + $container = $this->createMock(ContainerInterface::class); + $workflowManagerClass = 'OCA\\WorkflowEngine\\Manager'; + $this->workflowManager = new $workflowManagerClass( + $this->connection, + $container, + $l10n, + $generalLogger, + $this->userSession, + $this->dispatcher, + $appConfig, + $cacheFactory, + ); + $container->method('get') + ->willReturnCallback(function (string $id) use ($check, $entity, $operation, $flowLogger, $workflowLoggerClass, $workflowManagerClass): mixed { + return match (ltrim($id, '\\')) { + $workflowManagerClass => $this->workflowManager, + UserProfileFieldCheck::class => $check, + ProfileFieldValueEntity::class => $entity, + LogProfileFieldChangeOperation::class => $operation, + $workflowLoggerClass => $flowLogger, + default => throw new \RuntimeException(sprintf('Unknown service %s', $id)), + }; + }); + + $this->workflowManager->registerCheck($check); + $this->workflowManager->registerEntity($entity); + $this->workflowManager->registerOperation($operation); + + $scopeContextClass = 'OCA\\WorkflowEngine\\Helper\\ScopeContext'; + $this->workflowManager->addOperation( + LogProfileFieldChangeOperation::class, + 'profile-fields-operation-trigger', + [[ + 'class' => UserProfileFieldCheck::class, + 'operator' => 'is', + 'value' => json_encode([ + 'field_key' => self::FIELD_KEY, + 'value' => 'engineering', + ], JSON_THROW_ON_ERROR), + ]], + '', + new $scopeContextClass(IManager::SCOPE_ADMIN), + ProfileFieldValueEntity::class, + [\OCA\ProfileFields\Workflow\Event\ProfileFieldValueUpdatedEvent::class], + ); + + $workflowAppClass = 'OCA\\WorkflowEngine\\AppInfo\\Application'; + $workflowApp = new $workflowAppClass(); + $bootContext = $this->createMock(IBootContext::class); + $bootContext->expects($this->once()) + ->method('injectFn') + ->willReturnCallback(function (callable $fn) use ($container, $generalLogger): mixed { + return $fn($this->dispatcher, $container, $generalLogger); + }); + $workflowApp->boot($bootContext); + } + + protected function tearDown(): void { + $this->clearWorkflowTables(); + $this->deleteDefinitionIfExists(self::FIELD_KEY); + + foreach ($this->createdUserIds as $userId) { + $user = $this->userManager->get($userId); + if ($user !== null) { + $user->delete(); + } + } + + parent::tearDown(); + } + + public function testUpsertDispatchTriggersConfiguredWorkflowOperation(): void { + $userId = $this->createUser('pf_workflow_op_subject'); + $this->insertValueForUser($this->definition, $userId, 'finance'); + + $this->operationLogger->expects($this->once()) + ->method('warning') + ->with( + 'Profile field workflow rule matched', + $this->callback(static function (array $context) use ($userId): bool { + return ($context['rule_name'] ?? null) === 'profile-fields-operation-trigger' + && ($context['field_key'] ?? null) === self::FIELD_KEY + && ($context['user_uid'] ?? null) === $userId + && ($context['previous_value'] ?? null) === 'finance' + && ($context['current_value'] ?? null) === 'engineering'; + }), + ); + + $fieldValueService = new FieldValueService($this->fieldValueMapper, $this->dispatcher); + $fieldValueService->upsert($this->definition, $userId, 'engineering', 'admin'); + } + + private function createDefinition(string $fieldKey): FieldDefinition { + $definition = new FieldDefinition(); + $definition->setFieldKey($fieldKey); + $definition->setLabel('Department'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility(FieldVisibility::USERS->value); + $definition->setSortOrder(0); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime()); + $definition->setUpdatedAt(new \DateTime()); + + return $this->fieldDefinitionMapper->insert($definition); + } + + private function createUser(string $userId): string { + if ($this->userManager->get($userId) === null) { + $this->userManager->createUser($userId, $userId); + $this->createdUserIds[$userId] = $userId; + } + + return $userId; + } + + private function insertValueForUser(FieldDefinition $definition, string $userId, string $value): void { + $fieldValue = new FieldValue(); + $fieldValue->setFieldDefinitionId($definition->getId()); + $fieldValue->setUserUid($userId); + $fieldValue->setValueJson(json_encode(['value' => $value], JSON_THROW_ON_ERROR)); + $fieldValue->setCurrentVisibility(FieldVisibility::USERS->value); + $fieldValue->setUpdatedByUid($userId); + $fieldValue->setUpdatedAt(new \DateTime()); + + $this->fieldValueMapper->insert($fieldValue); + } + + private function clearWorkflowTables(): void { + foreach (['flow_operations_scope', 'flow_operations', 'flow_checks'] as $table) { + $this->connection->getQueryBuilder() + ->delete($table) + ->executeStatement(); + } + } + + private function deleteDefinitionIfExists(string $fieldKey): void { + $definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey); + if ($definition === null) { + return; + } + + foreach ($this->fieldValueMapper->findByFieldDefinitionId($definition->getId()) as $value) { + $this->fieldValueMapper->delete($value); + } + + $this->fieldDefinitionMapper->delete($definition); + } + + private static function ensureSchemaExists(IDBConnection $connection): void { + if ($connection->tableExists('profile_fields_definitions') && $connection->tableExists('profile_fields_values')) { + return; + } + + $nullOutputClass = '\\OC\\Migration\\NullOutput'; + $schemaWrapperClass = '\\OC\\DB\\SchemaWrapper'; + + if (!class_exists($nullOutputClass) || !class_exists($schemaWrapperClass) || !method_exists($connection, 'getInner')) { + throw new \RuntimeException('Expected ConnectionAdapter in integration test setup'); + } + + $migration = new Version1000Date20260309120000(); + /** @var IOutput $output */ + $output = new $nullOutputClass(); + $schema = $migration->changeSchema( + $output, + static function () use ($connection, $schemaWrapperClass): ISchemaWrapper { + /** @var ISchemaWrapper $schemaWrapper */ + $schemaWrapper = new $schemaWrapperClass(call_user_func([$connection, 'getInner'])); + + return $schemaWrapper; + }, + [], + ); + + if ($schema instanceof ISchemaWrapper && method_exists($schema, 'getWrappedSchema')) { + /** @var \Doctrine\DBAL\Schema\Schema $wrappedSchema */ + $wrappedSchema = call_user_func([$schema, 'getWrappedSchema']); + $connection->migrateToSchema($wrappedSchema); + } + } +} diff --git a/tests/php/ControllerIntegration/Workflow/UserProfileFieldCheckIntegrationTest.php b/tests/php/ControllerIntegration/Workflow/UserProfileFieldCheckIntegrationTest.php new file mode 100644 index 0000000..171762f --- /dev/null +++ b/tests/php/ControllerIntegration/Workflow/UserProfileFieldCheckIntegrationTest.php @@ -0,0 +1,369 @@ + */ + private array $createdUserIds = []; + + protected function setUp(): void { + parent::setUp(); + + $app = new \OCP\AppFramework\App(Application::APP_ID); + $appContainer = $app->getContainer(); + + $this->fieldDefinitionMapper = $appContainer->get(FieldDefinitionMapper::class); + $this->fieldValueMapper = $appContainer->get(FieldValueMapper::class); + $this->fieldDefinitionService = $appContainer->get(FieldDefinitionService::class); + $this->fieldValueService = $appContainer->get(FieldValueService::class); + $this->userManager = $appContainer->get(IUserManager::class); + $this->connection = $appContainer->get(IDBConnection::class); + + self::ensureSchemaExists($this->connection); + $this->clearWorkflowTables(); + $this->deleteDefinitionIfExists(self::FIELD_KEY_MATCH); + $this->deleteDefinitionIfExists(self::FIELD_KEY_CREATOR); + + $this->userSession = $this->createMock(IUserSession::class); + $this->userSession->method('getUser') + ->willReturnCallback(fn (): ?IUser => $this->currentUser); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t') + ->willReturnCallback(static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters)); + + $generalLogger = $this->createMock(LoggerInterface::class); + $workflowLoggerClass = 'OCA\\WorkflowEngine\\Service\\Logger'; + $flowLogger = $this->createMock($workflowLoggerClass); + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueBool')->willReturn(false); + + $cache = $this->createMock(ICache::class); + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($cache); + + $check = new UserProfileFieldCheck( + $this->userSession, + $l10n, + $this->fieldDefinitionService, + $this->fieldValueService, + new ProfileFieldValueSubjectContext(), + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get') + ->willReturnCallback(function (string $id) use ($flowLogger, $check, $workflowLoggerClass): mixed { + return match (ltrim($id, '\\')) { + $workflowLoggerClass => $flowLogger, + UserProfileFieldCheck::class => $check, + UserProfileFieldCheckIntegrationTestOperation::class => new UserProfileFieldCheckIntegrationTestOperation(), + UserProfileFieldCheckIntegrationTestEntity::class => new UserProfileFieldCheckIntegrationTestEntity(), + default => throw new class(sprintf('Unknown service %s', $id)) extends \RuntimeException implements ContainerExceptionInterface { + }, + }; + }); + + $workflowManagerClass = 'OCA\\WorkflowEngine\\Manager'; + $this->workflowManager = new $workflowManagerClass( + $this->connection, + $container, + $l10n, + $generalLogger, + $this->userSession, + $eventDispatcher, + $appConfig, + $cacheFactory, + ); + } + + protected function tearDown(): void { + $this->currentUser = null; + $this->clearWorkflowTables(); + $this->deleteDefinitionIfExists(self::FIELD_KEY_MATCH); + $this->deleteDefinitionIfExists(self::FIELD_KEY_CREATOR); + + foreach ($this->createdUserIds as $userId) { + $user = $this->userManager->get($userId); + if ($user instanceof IUser) { + $user->delete(); + } + } + + parent::tearDown(); + } + + public function testConfiguredWorkflowRuleMatchesCurrentSessionUser(): void { + $this->createWorkflowRuleForDepartment(self::FIELD_KEY_MATCH, 'engineering'); + + $matchingUserId = $this->createUser('pf_workflow_match'); + $nonMatchingUserId = $this->createUser('pf_workflow_miss'); + $this->insertValueForUser(self::FIELD_KEY_MATCH, $matchingUserId, '{"value":"engineering"}'); + $this->insertValueForUser(self::FIELD_KEY_MATCH, $nonMatchingUserId, '{"value":"finance"}'); + + $this->currentUser = $this->userManager->get($matchingUserId); + $match = $this->newRuleMatcher()->getFlows(); + $this->assertIsArray($match); + $this->assertSame('profile-fields-current-user-check', $match['name'] ?? null); + + $this->currentUser = $this->userManager->get($nonMatchingUserId); + $this->assertSame([], $this->newRuleMatcher()->getFlows()); + } + + public function testConfiguredWorkflowRuleFollowsCurrentAuthenticatedUserInsteadOfRuleCreator(): void { + $adminUserId = $this->createUser('pf_workflow_admin'); + $this->createWorkflowRuleForDepartment(self::FIELD_KEY_CREATOR, 'engineering'); + $this->insertValueForUser(self::FIELD_KEY_CREATOR, $adminUserId, '{"value":"finance"}'); + + $this->currentUser = $this->userManager->get($adminUserId); + $this->assertSame([], $this->newRuleMatcher()->getFlows()); + + $subjectUserId = $this->createUser('pf_workflow_subject'); + $this->insertValueForUser(self::FIELD_KEY_CREATOR, $subjectUserId, '{"value":"engineering"}'); + + $this->currentUser = $this->userManager->get($subjectUserId); + $match = $this->newRuleMatcher()->getFlows(); + $this->assertIsArray($match); + $this->assertSame('profile-fields-current-user-check', $match['name'] ?? null); + } + + private function createWorkflowRuleForDepartment(string $fieldKey, string $expectedDepartment): void { + $definition = new FieldDefinition(); + $definition->setFieldKey($fieldKey); + $definition->setLabel('Department'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility(FieldVisibility::USERS->value); + $definition->setSortOrder(0); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime()); + $definition->setUpdatedAt(new \DateTime()); + + $this->fieldDefinitionMapper->insert($definition); + + $scopeContextClass = 'OCA\\WorkflowEngine\\Helper\\ScopeContext'; + + $this->workflowManager->addOperation( + UserProfileFieldCheckIntegrationTestOperation::class, + 'profile-fields-current-user-check', + [[ + 'class' => UserProfileFieldCheck::class, + 'operator' => 'is', + 'value' => json_encode([ + 'field_key' => $fieldKey, + 'value' => $expectedDepartment, + ], JSON_THROW_ON_ERROR), + ]], + '', + new $scopeContextClass(IManager::SCOPE_ADMIN), + UserProfileFieldCheckIntegrationTestEntity::class, + [], + ); + } + + private function newRuleMatcher(): IRuleMatcher { + $ruleMatcher = $this->workflowManager->getRuleMatcher(); + $ruleMatcher->setOperation(new UserProfileFieldCheckIntegrationTestOperation()); + + return $ruleMatcher; + } + + private function createUser(string $userId): string { + if ($this->userManager->get($userId) === null) { + $this->userManager->createUser($userId, $userId); + $this->createdUserIds[$userId] = $userId; + } + + return $userId; + } + + private function insertValueForUser(string $fieldKey, string $userId, string $valueJson): void { + $definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey); + if ($definition === null) { + throw new \RuntimeException('Expected workflow test field definition'); + } + + $value = new FieldValue(); + $value->setFieldDefinitionId($definition->getId()); + $value->setUserUid($userId); + $value->setValueJson($valueJson); + $value->setCurrentVisibility(FieldVisibility::USERS->value); + $value->setUpdatedByUid($userId); + $value->setUpdatedAt(new \DateTime()); + + $this->fieldValueMapper->insert($value); + } + + private function clearWorkflowTables(): void { + foreach (['flow_operations_scope', 'flow_operations', 'flow_checks'] as $table) { + $this->connection->getQueryBuilder() + ->delete($table) + ->executeStatement(); + } + } + + private function deleteDefinitionIfExists(string $fieldKey): void { + $definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey); + if ($definition === null) { + return; + } + + foreach ($this->fieldValueMapper->findByFieldDefinitionId($definition->getId()) as $value) { + $this->fieldValueMapper->delete($value); + } + + $this->fieldDefinitionMapper->delete($definition); + } + + private static function ensureSchemaExists(IDBConnection $connection): void { + if ($connection->tableExists('profile_fields_definitions') && $connection->tableExists('profile_fields_values')) { + return; + } + + $nullOutputClass = '\\OC\\Migration\\NullOutput'; + $schemaWrapperClass = '\\OC\\DB\\SchemaWrapper'; + + if (!class_exists($nullOutputClass) || !class_exists($schemaWrapperClass) || !method_exists($connection, 'getInner')) { + throw new \RuntimeException('Expected ConnectionAdapter in integration test setup'); + } + + $migration = new Version1000Date20260309120000(); + /** @var IOutput $output */ + $output = new $nullOutputClass(); + $schema = $migration->changeSchema( + $output, + static function () use ($connection, $schemaWrapperClass): ISchemaWrapper { + /** @var ISchemaWrapper $schemaWrapper */ + $schemaWrapper = new $schemaWrapperClass(call_user_func([$connection, 'getInner'])); + + return $schemaWrapper; + }, + [], + ); + + if ($schema instanceof ISchemaWrapper && method_exists($schema, 'getWrappedSchema')) { + /** @var \Doctrine\DBAL\Schema\Schema $wrappedSchema */ + $wrappedSchema = call_user_func([$schema, 'getWrappedSchema']); + $connection->migrateToSchema($wrappedSchema); + } + } +} diff --git a/tests/php/Unit/AppInfo/ApplicationTest.php b/tests/php/Unit/AppInfo/ApplicationTest.php index 6f32355..1b0b450 100644 --- a/tests/php/Unit/AppInfo/ApplicationTest.php +++ b/tests/php/Unit/AppInfo/ApplicationTest.php @@ -10,11 +10,42 @@ namespace OCA\ProfileFields\Tests\Unit\AppInfo; use OCA\ProfileFields\AppInfo\Application; +use OCA\ProfileFields\Listener\LoadWorkflowSettingsScriptsListener; +use OCA\ProfileFields\Listener\RegisterWorkflowCheckListener; +use OCA\ProfileFields\Listener\RegisterWorkflowEntityListener; +use OCA\ProfileFields\Listener\RegisterWorkflowOperationListener; use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\IRequest; +use OCP\User\Events\UserDeletedEvent; +use OCP\WorkflowEngine\Events\LoadSettingsScriptsEvent; +use OCP\WorkflowEngine\Events\RegisterChecksEvent; +use OCP\WorkflowEngine\Events\RegisterEntitiesEvent; +use OCP\WorkflowEngine\Events\RegisterOperationsEvent; use PHPUnit\Framework\TestCase; class ApplicationTest extends TestCase { + public function testRegisterAddsWorkflowListeners(): void { + $registrations = []; + + $registrationContext = $this->createMock(IRegistrationContext::class); + $registrationContext->expects($this->exactly(6)) + ->method('registerEventListener') + ->willReturnCallback(static function (string $event, string $listener, int $priority = 0) use (&$registrations): void { + $registrations[] = [$event, $listener, $priority]; + }); + + $application = new Application(); + $application->register($registrationContext); + + self::assertContains(['\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent', 'OCA\\ProfileFields\\Listener\\BeforeTemplateRenderedListener', 0], $registrations); + self::assertContains([UserDeletedEvent::class, 'OCA\\ProfileFields\\Listener\\UserDeletedCleanupListener', 0], $registrations); + self::assertContains([RegisterEntitiesEvent::class, RegisterWorkflowEntityListener::class, 0], $registrations); + self::assertContains([RegisterOperationsEvent::class, RegisterWorkflowOperationListener::class, 0], $registrations); + self::assertContains([RegisterChecksEvent::class, RegisterWorkflowCheckListener::class, 0], $registrations); + self::assertContains([LoadSettingsScriptsEvent::class, LoadWorkflowSettingsScriptsListener::class, 0], $registrations); + } + public function testBootIgnoresUnsupportedRequestContext(): void { $request = $this->createMock(IRequest::class); $request->expects($this->once()) diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 50ee731..7f3b989 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -15,17 +15,23 @@ use OCA\ProfileFields\Db\FieldValueMapper; use OCA\ProfileFields\Enum\FieldType; use OCA\ProfileFields\Service\FieldValueService; +use OCA\ProfileFields\Workflow\Event\ProfileFieldValueCreatedEvent; +use OCA\ProfileFields\Workflow\Event\ProfileFieldValueUpdatedEvent; +use OCA\ProfileFields\Workflow\Event\ProfileFieldVisibilityUpdatedEvent; +use OCP\EventDispatcher\IEventDispatcher; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class FieldValueServiceTest extends TestCase { private FieldValueMapper&MockObject $fieldValueMapper; + private IEventDispatcher&MockObject $eventDispatcher; private FieldValueService $service; protected function setUp(): void { parent::setUp(); $this->fieldValueMapper = $this->createMock(FieldValueMapper::class); - $this->service = new FieldValueService($this->fieldValueMapper); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->service = new FieldValueService($this->fieldValueMapper, $this->eventDispatcher); } public function testNormalizeNumberValue(): void { @@ -45,6 +51,7 @@ public function testNormalizeMissingValueAsNull(): void { public function testUpsertPersistsSerializedValue(): void { $definition = $this->buildDefinition(FieldType::NUMBER->value); $definition->setId(3); + $definition->setFieldKey('score'); $definition->setInitialVisibility('users'); $this->fieldValueMapper @@ -65,6 +72,17 @@ public function testUpsertPersistsSerializedValue(): void { return true; })) ->willReturnCallback(static fn (FieldValue $value): FieldValue => $value); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (object $event): bool { + $this->assertInstanceOf(ProfileFieldValueCreatedEvent::class, $event); + $this->assertSame('alice', $event->getWorkflowSubject()->getUserUid()); + $this->assertSame('admin', $event->getWorkflowSubject()->getActorUid()); + $this->assertSame('score', $event->getWorkflowSubject()->getFieldDefinition()->getFieldKey()); + $this->assertSame(9.5, $event->getWorkflowSubject()->getCurrentValue()); + $this->assertNull($event->getWorkflowSubject()->getPreviousValue()); + return true; + })); $stored = $this->service->upsert($definition, 'alice', '9.5', 'admin'); @@ -74,6 +92,7 @@ public function testUpsertPersistsSerializedValue(): void { public function testUpsertPreservesImportedUpdatedAt(): void { $definition = $this->buildDefinition(FieldType::TEXT->value); $definition->setId(3); + $definition->setFieldKey('department'); $definition->setInitialVisibility('users'); $this->fieldValueMapper @@ -89,6 +108,9 @@ public function testUpsertPreservesImportedUpdatedAt(): void { return true; })) ->willReturnCallback(static fn (FieldValue $value): FieldValue => $value); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(ProfileFieldValueCreatedEvent::class)); $stored = $this->service->upsert( $definition, @@ -102,6 +124,40 @@ public function testUpsertPreservesImportedUpdatedAt(): void { $this->assertSame('2026-03-12T14:00:00+00:00', $stored->getUpdatedAt()->format(DATE_ATOM)); } + public function testUpsertDispatchesUpdatedEventWhenExistingValueChanges(): void { + $definition = $this->buildDefinition(FieldType::TEXT->value); + $definition->setId(3); + $definition->setFieldKey('department'); + $definition->setInitialVisibility('users'); + + $existing = new FieldValue(); + $existing->setId(10); + $existing->setFieldDefinitionId(3); + $existing->setUserUid('alice'); + $existing->setValueJson('{"value":"finance"}'); + $existing->setCurrentVisibility('users'); + $existing->setUpdatedByUid('alice'); + $existing->setUpdatedAt(new \DateTime()); + + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(3, 'alice') + ->willReturn($existing); + $this->fieldValueMapper->expects($this->once()) + ->method('update') + ->willReturnCallback(static fn (FieldValue $value): FieldValue => $value); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (object $event): bool { + $this->assertInstanceOf(ProfileFieldValueUpdatedEvent::class, $event); + $this->assertSame('finance', $event->getWorkflowSubject()->getPreviousValue()); + $this->assertSame('engineering', $event->getWorkflowSubject()->getCurrentValue()); + return true; + })); + + $this->service->upsert($definition, 'alice', 'engineering', 'admin'); + } + public function testSerializeForResponseReturnsDecodedPayload(): void { $value = new FieldValue(); $value->setId(10); @@ -142,6 +198,7 @@ public function testSerializeForResponseRejectsInvalidJson(): void { public function testUpdateVisibilityUpdatesExistingValue(): void { $definition = $this->buildDefinition(FieldType::TEXT->value); $definition->setId(3); + $definition->setFieldKey('department'); $value = new FieldValue(); $value->setId(10); $value->setFieldDefinitionId(3); @@ -164,6 +221,14 @@ public function testUpdateVisibilityUpdatesExistingValue(): void { return true; })) ->willReturnCallback(static fn (FieldValue $updated): FieldValue => $updated); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (object $event): bool { + $this->assertInstanceOf(ProfileFieldVisibilityUpdatedEvent::class, $event); + $this->assertSame('private', $event->getWorkflowSubject()->getPreviousVisibility()); + $this->assertSame('users', $event->getWorkflowSubject()->getCurrentVisibility()); + return true; + })); $updated = $this->service->updateVisibility($definition, 'alice', 'admin', 'users'); diff --git a/tests/php/Unit/Workflow/CreateTalkConversationProfileFieldChangeOperationTest.php b/tests/php/Unit/Workflow/CreateTalkConversationProfileFieldChangeOperationTest.php new file mode 100644 index 0000000..e46ea21 --- /dev/null +++ b/tests/php/Unit/Workflow/CreateTalkConversationProfileFieldChangeOperationTest.php @@ -0,0 +1,139 @@ +broker = $this->createMock(IBroker::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->userManager = $this->createMock(IUserManager::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static function (string $text, array $parameters = []): string { + return $parameters === [] ? $text : vsprintf($text, $parameters); + }); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('imagePath') + ->with('core', 'actions/comment.svg') + ->willReturn('/core/img/actions/comment.svg'); + + $this->operation = new CreateTalkConversationProfileFieldChangeOperation($this->broker, $this->groupManager, $this->userManager, $l10n, $urlGenerator, new ProfileFieldValueSubjectContext()); + } + + public function testGetIconReturnsCommentIcon(): void { + $this->assertSame('/core/img/actions/comment.svg', $this->operation->getIcon()); + } + + public function testOnEventCreatesConversationWhenTalkBackendIsAvailable(): void { + $definition = $this->createFieldDefinition(); + $event = new ProfileFieldValueUpdatedEvent(new ProfileFieldValueWorkflowSubject( + userUid: 'alice', + actorUid: 'admin', + fieldDefinition: $definition, + currentValue: 'engineering', + previousValue: 'finance', + currentVisibility: 'users', + previousVisibility: 'private', + )); + + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->once())->method('getFlows')->with(false)->willReturn([ + ['id' => 41, 'name' => 'talk'], + ]); + + $alice = $this->createMock(IUser::class); + $alice->method('getUID')->willReturn('alice'); + $admin = $this->createMock(IUser::class); + $admin->method('getUID')->willReturn('admin'); + $group = $this->createMock(IGroup::class); + $group->method('getUsers')->willReturn([$admin]); + + $this->userManager->method('get')->willReturnMap([ + ['alice', $alice], + ]); + $this->groupManager->method('get')->with('admin')->willReturn($group); + $this->broker->expects($this->once())->method('hasBackend')->willReturn(true); + $this->broker->expects($this->once()) + ->method('createConversation') + ->with( + 'Profile field change: Department for alice', + $this->callback(function (array $moderators): bool { + $this->assertCount(2, $moderators); + return true; + }), + null, + ); + + $this->operation->onEvent(ProfileFieldValueUpdatedEvent::class, $event, $ruleMatcher); + } + + public function testOnEventSkipsWhenTalkBackendIsUnavailable(): void { + $definition = $this->createFieldDefinition(); + $event = new ProfileFieldValueUpdatedEvent(new ProfileFieldValueWorkflowSubject( + userUid: 'alice', + actorUid: 'admin', + fieldDefinition: $definition, + currentValue: 'engineering', + previousValue: 'finance', + currentVisibility: 'users', + previousVisibility: 'private', + )); + + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->once())->method('getFlows')->with(false)->willReturn([ + ['id' => 41, 'name' => 'talk'], + ]); + + $this->broker->expects($this->once())->method('hasBackend')->willReturn(false); + $this->broker->expects($this->never())->method('createConversation'); + + $this->operation->onEvent(ProfileFieldValueUpdatedEvent::class, $event, $ruleMatcher); + } + + private function createFieldDefinition(): FieldDefinition { + $definition = new FieldDefinition(); + $definition->setId(7); + $definition->setFieldKey('department'); + $definition->setLabel('Department'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility('users'); + $definition->setSortOrder(1); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime()); + $definition->setUpdatedAt(new \DateTime()); + return $definition; + } +} diff --git a/tests/php/Unit/Workflow/EmailUserProfileFieldChangeOperationTest.php b/tests/php/Unit/Workflow/EmailUserProfileFieldChangeOperationTest.php new file mode 100644 index 0000000..3087a6f --- /dev/null +++ b/tests/php/Unit/Workflow/EmailUserProfileFieldChangeOperationTest.php @@ -0,0 +1,177 @@ +mailer = $this->createMock(IMailer::class); + $this->userManager = $this->createMock(IUserManager::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('imagePath') + ->with('core', 'actions/mail.svg') + ->willReturn('/core/img/actions/mail.svg'); + + $this->operation = new EmailUserProfileFieldChangeOperation($this->mailer, $this->userManager, $l10n, $urlGenerator, new ProfileFieldValueSubjectContext()); + } + + public function testGetIconReturnsMailIcon(): void { + $this->assertSame('/core/img/actions/mail.svg', $this->operation->getIcon()); + } + + public function testValidateOperationRejectsCustomConfiguration(): void { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('A valid email template configuration is required'); + + $this->operation->validateOperation('email-user', [], 'custom'); + } + + public function testValidateOperationAcceptsTemplateConfiguration(): void { + $this->operation->validateOperation('email-user', [], '{"subjectTemplate":"Profile update: {{fieldLabel}}","bodyTemplate":"Field {{fieldLabel}} changed from {{previousValue}} to {{currentValue}}."}'); + $this->assertTrue(true); + } + + public function testOnEventSendsMailToAffectedUser(): void { + $definition = new FieldDefinition(); + $definition->setId(7); + $definition->setFieldKey('department'); + $definition->setLabel('Department'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility('users'); + $definition->setSortOrder(1); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime()); + $definition->setUpdatedAt(new \DateTime()); + + $event = new ProfileFieldValueUpdatedEvent(new ProfileFieldValueWorkflowSubject( + userUid: 'alice', + actorUid: 'admin', + fieldDefinition: $definition, + currentValue: 'engineering', + previousValue: 'finance', + currentVisibility: 'users', + previousVisibility: 'private', + )); + + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->once()) + ->method('getFlows') + ->with(false) + ->willReturn([ + [ + 'id' => 17, + 'name' => 'email-user', + 'operation' => '{"subjectTemplate":"Update: {{fieldLabel}}","bodyTemplate":"Field {{fieldLabel}} changed from {{previousValue}} to {{currentValue}} by {{actorUid}}."}', + ], + ]); + + $user = $this->createMock(IUser::class); + $user->expects($this->once())->method('getEMailAddress')->willReturn('alice@example.test'); + $user->expects($this->once())->method('getDisplayName')->willReturn('Alice'); + $this->userManager->expects($this->once())->method('get')->with('alice')->willReturn($user); + + $message = $this->createMock(IMessage::class); + $message->expects($this->once())->method('setTo')->with(['alice@example.test' => 'Alice'])->willReturnSelf(); + $message->expects($this->once())->method('setSubject')->with('Update: Department')->willReturnSelf(); + $message->expects($this->once()) + ->method('setPlainBody') + ->with($this->callback(function (string $body): bool { + $this->assertSame('Field Department changed from finance to engineering by admin.', $body); + $this->assertStringContainsString('admin', $body); + return true; + })) + ->willReturnSelf(); + + $this->mailer->expects($this->once())->method('createMessage')->willReturn($message); + $this->mailer->expects($this->once())->method('send')->with($message)->willReturn([]); + + $this->operation->onEvent(ProfileFieldValueUpdatedEvent::class, $event, $ruleMatcher); + } + + public function testOnEventSkipsMailWhenAffectedUserHasNoEmail(): void { + $definition = new FieldDefinition(); + $definition->setId(7); + $definition->setFieldKey('department'); + $definition->setLabel('Department'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility('users'); + $definition->setSortOrder(1); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime()); + $definition->setUpdatedAt(new \DateTime()); + + $event = new ProfileFieldValueUpdatedEvent(new ProfileFieldValueWorkflowSubject( + userUid: 'alice', + actorUid: 'admin', + fieldDefinition: $definition, + currentValue: 'engineering', + previousValue: 'finance', + currentVisibility: 'users', + previousVisibility: 'private', + )); + + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->once()) + ->method('getFlows') + ->with(false) + ->willReturn([ + ['id' => 17, 'name' => 'email-user', 'operation' => ''], + ]); + + $user = $this->createMock(IUser::class); + $user->expects($this->once())->method('getEMailAddress')->willReturn(null); + $user->expects($this->never())->method('getDisplayName'); + $this->userManager->expects($this->once())->method('get')->with('alice')->willReturn($user); + + $this->mailer->expects($this->never())->method('createMessage'); + $this->mailer->expects($this->never())->method('send'); + + $this->operation->onEvent(ProfileFieldValueUpdatedEvent::class, $event, $ruleMatcher); + } + + public function testOnEventIgnoresUnsupportedEvents(): void { + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->never())->method('getFlows'); + $this->userManager->expects($this->never())->method('get'); + $this->mailer->expects($this->never())->method('createMessage'); + + $this->operation->onEvent('unsupported', new Event(), $ruleMatcher); + } +} diff --git a/tests/php/Unit/Workflow/LogProfileFieldChangeOperationTest.php b/tests/php/Unit/Workflow/LogProfileFieldChangeOperationTest.php new file mode 100644 index 0000000..ec2eef3 --- /dev/null +++ b/tests/php/Unit/Workflow/LogProfileFieldChangeOperationTest.php @@ -0,0 +1,107 @@ +logger = $this->createMock(LoggerInterface::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('imagePath') + ->with('core', 'actions/history.svg') + ->willReturn('/core/img/actions/history.svg'); + + $this->operation = new LogProfileFieldChangeOperation($this->logger, $l10n, $urlGenerator, new ProfileFieldValueSubjectContext()); + } + + public function testGetIconReturnsHistoryIcon(): void { + $this->assertSame('/core/img/actions/history.svg', $this->operation->getIcon()); + } + + public function testOnEventLogsEveryMatchingRule(): void { + $definition = new FieldDefinition(); + $definition->setId(7); + $definition->setFieldKey('department'); + $definition->setLabel('Department'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility('users'); + $definition->setSortOrder(1); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime()); + $definition->setUpdatedAt(new \DateTime()); + + $event = new ProfileFieldValueUpdatedEvent(new ProfileFieldValueWorkflowSubject( + userUid: 'alice', + actorUid: 'admin', + fieldDefinition: $definition, + currentValue: 'engineering', + previousValue: 'finance', + currentVisibility: 'users', + previousVisibility: 'users', + )); + + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->once()) + ->method('getFlows') + ->with(false) + ->willReturn([ + ['id' => 11, 'name' => 'dept-change', 'operation' => ''], + ]); + + $this->logger->expects($this->once()) + ->method('warning') + ->with( + 'Profile field workflow rule matched', + $this->callback(function (array $context): bool { + $this->assertSame(11, $context['rule_id']); + $this->assertSame('dept-change', $context['rule_name']); + $this->assertSame('department', $context['field_key']); + $this->assertSame('alice', $context['user_uid']); + $this->assertSame('admin', $context['actor_uid']); + $this->assertSame('finance', $context['previous_value']); + $this->assertSame('engineering', $context['current_value']); + return true; + }), + ); + + $this->operation->onEvent(ProfileFieldValueUpdatedEvent::class, $event, $ruleMatcher); + } + + public function testOnEventIgnoresUnsupportedEvents(): void { + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->never())->method('getFlows'); + $this->logger->expects($this->never())->method('warning'); + + $this->operation->onEvent('unsupported', new Event(), $ruleMatcher); + } +} diff --git a/tests/php/Unit/Workflow/NotifyAdminsOrGroupsProfileFieldChangeOperationTest.php b/tests/php/Unit/Workflow/NotifyAdminsOrGroupsProfileFieldChangeOperationTest.php new file mode 100644 index 0000000..2da9243 --- /dev/null +++ b/tests/php/Unit/Workflow/NotifyAdminsOrGroupsProfileFieldChangeOperationTest.php @@ -0,0 +1,151 @@ +notificationManager = $this->createMock(IManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->l10n = $this->createMock(IL10N::class); + $this->l10n->method('t')->willReturnCallback(static function (string $text, array|string $parameters = []): string { + if (!is_array($parameters) || $parameters === []) { + return $text; + } + + return str_replace( + ['%1$s', '%2$s', '%3$s'], + array_map(static fn (mixed $parameter): string => (string)$parameter, $parameters), + $text, + ); + }); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('imagePath') + ->with('core', 'actions/user-admin.svg') + ->willReturn('/core/img/actions/user-admin.svg'); + $urlGenerator->method('getAbsoluteURL') + ->with('/core/img/actions/user-admin.svg') + ->willReturn('https://localhost/core/img/actions/user-admin.svg'); + + $this->operation = new NotifyAdminsOrGroupsProfileFieldChangeOperation($this->notificationManager, $this->groupManager, $this->userManager, $this->l10n, $urlGenerator, new ProfileFieldValueSubjectContext()); + } + + public function testGetIconReturnsAdminIcon(): void { + $this->assertSame('/core/img/actions/user-admin.svg', $this->operation->getIcon()); + } + + public function testValidateOperationRejectsInvalidTargets(): void { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('A valid target list is required'); + + $this->operation->validateOperation('notify-admins-or-groups', [], '{"targets":"invalid"}'); + } + + public function testOnEventNotifiesResolvedTargets(): void { + $definition = $this->createFieldDefinition(); + $event = new ProfileFieldValueUpdatedEvent(new ProfileFieldValueWorkflowSubject( + userUid: 'alice', + actorUid: 'admin', + fieldDefinition: $definition, + currentValue: 'engineering', + previousValue: 'finance', + currentVisibility: 'users', + previousVisibility: 'private', + )); + + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->once()) + ->method('getFlows') + ->with(false) + ->willReturn([ + ['id' => 31, 'name' => 'notify-targets', 'operation' => '{"targets":"user:bob,group:staff"}'], + ]); + + $bob = $this->createMock(IUser::class); + $bob->method('getUID')->willReturn('bob'); + $staffUser = $this->createMock(IUser::class); + $staffUser->method('getUID')->willReturn('carol'); + $group = $this->createMock(IGroup::class); + $group->method('getUsers')->willReturn([$staffUser]); + + $this->userManager->method('get')->willReturnMap([ + ['bob', $bob], + ]); + $this->groupManager->method('get')->willReturnMap([ + ['staff', $group], + ]); + + $notifications = []; + $this->notificationManager->expects($this->exactly(2)) + ->method('createNotification') + ->willReturnCallback(function () use (&$notifications) { + $notification = $this->createMock(INotification::class); + $notification->method('setApp')->with(Application::APP_ID)->willReturnSelf(); + $notification->method('setObject')->willReturnSelf(); + $notification->method('setDateTime')->willReturnSelf(); + $notification->method('setSubject')->with('profile_field_updated')->willReturnSelf(); + $notification->method('setMessage')->with('profile_field_updated_message', ['admin', 'alice', 'Department'])->willReturnSelf(); + $notification->method('setParsedSubject')->with('Profile field updated')->willReturnSelf(); + $notification->method('setParsedMessage')->with('admin changed alice\'s Department profile field.')->willReturnSelf(); + $notification->method('setIcon')->willReturnSelf(); + $notification->expects($this->once())->method('setUser')->with($this->logicalOr('bob', 'carol'))->willReturnSelf(); + $notifications[] = $notification; + return $notification; + }); + $this->notificationManager->expects($this->exactly(2))->method('notify'); + + $this->operation->onEvent(ProfileFieldValueUpdatedEvent::class, $event, $ruleMatcher); + $this->addToAssertionCount(1); + } + + private function createFieldDefinition(): FieldDefinition { + $definition = new FieldDefinition(); + $definition->setId(7); + $definition->setFieldKey('department'); + $definition->setLabel('Department'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility('users'); + $definition->setSortOrder(1); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime()); + $definition->setUpdatedAt(new \DateTime()); + return $definition; + } +} diff --git a/tests/php/Unit/Workflow/SendWebhookProfileFieldChangeOperationTest.php b/tests/php/Unit/Workflow/SendWebhookProfileFieldChangeOperationTest.php new file mode 100644 index 0000000..774b9da --- /dev/null +++ b/tests/php/Unit/Workflow/SendWebhookProfileFieldChangeOperationTest.php @@ -0,0 +1,148 @@ +clientService = $this->createMock(IClientService::class); + $this->client = $this->createMock(IClient::class); + $this->clientService->method('newClient')->willReturn($this->client); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('imagePath') + ->with('core', 'actions/share.svg') + ->willReturn('/core/img/actions/share.svg'); + + $this->operation = new SendWebhookProfileFieldChangeOperation($this->clientService, $l10n, $urlGenerator, new ProfileFieldValueSubjectContext()); + } + + public function testGetIconReturnsWebhookIcon(): void { + $this->assertSame('/core/img/actions/share.svg', $this->operation->getIcon()); + } + + public function testValidateOperationRejectsMissingWebhookUrl(): void { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('A valid HTTP or HTTPS webhook URL is required'); + + $this->operation->validateOperation('send-webhook', [], ''); + } + + public function testValidateOperationRejectsUnsupportedScheme(): void { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('A valid HTTP or HTTPS webhook URL is required'); + + $this->operation->validateOperation('send-webhook', [], 'ftp://example.test/hook'); + } + + public function testValidateOperationAcceptsJsonConfiguration(): void { + $this->operation->validateOperation('send-webhook', [], '{"url":"https://example.test/hooks/profile-fields","secret":"shared-secret","timeout":10,"retries":2,"headers":{"X-Environment":"test"}}'); + $this->assertTrue(true); + } + + public function testOnEventPostsStructuredWebhookPayload(): void { + $definition = new FieldDefinition(); + $definition->setId(7); + $definition->setFieldKey('department'); + $definition->setLabel('Department'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility('users'); + $definition->setSortOrder(1); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime()); + $definition->setUpdatedAt(new \DateTime()); + + $event = new ProfileFieldValueUpdatedEvent(new ProfileFieldValueWorkflowSubject( + userUid: 'alice', + actorUid: 'admin', + fieldDefinition: $definition, + currentValue: 'engineering', + previousValue: 'finance', + currentVisibility: 'users', + previousVisibility: 'private', + )); + + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->once()) + ->method('getFlows') + ->with(false) + ->willReturn([ + [ + 'id' => 12, + 'name' => 'send-webhook', + 'operation' => '{"url":"https://example.test/hooks/profile-fields","secret":"shared-secret","timeout":10,"retries":2,"headers":{"X-Environment":"test"}}', + ], + ]); + + $this->client->expects($this->once()) + ->method('post') + ->with( + 'https://example.test/hooks/profile-fields', + $this->callback(function (array $options): bool { + $this->assertSame('application/json', $options['headers']['Content-Type'] ?? null); + $this->assertSame('application/json', $options['headers']['Accept'] ?? null); + $this->assertSame('test', $options['headers']['X-Environment'] ?? null); + $this->assertSame(10, $options['timeout'] ?? null); + $this->assertStringStartsWith('sha256=', $options['headers']['X-Profile-Fields-Signature'] ?? ''); + $this->assertNotSame('', $options['headers']['X-Profile-Fields-Timestamp'] ?? ''); + $this->assertIsString($options['body'] ?? null); + + $payload = json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR); + $this->assertSame(12, $payload['rule']['id'] ?? null); + $this->assertSame('send-webhook', $payload['rule']['name'] ?? null); + $this->assertSame('department', $payload['field']['key'] ?? null); + $this->assertSame('Department', $payload['field']['label'] ?? null); + $this->assertSame('alice', $payload['user']['uid'] ?? null); + $this->assertSame('admin', $payload['actor']['uid'] ?? null); + $this->assertSame('finance', $payload['change']['previousValue'] ?? null); + $this->assertSame('engineering', $payload['change']['currentValue'] ?? null); + $this->assertSame('private', $payload['change']['previousVisibility'] ?? null); + $this->assertSame('users', $payload['change']['currentVisibility'] ?? null); + $this->assertSame(ProfileFieldValueUpdatedEvent::class, $payload['event']['name'] ?? null); + return true; + }) + ); + + $this->operation->onEvent(ProfileFieldValueUpdatedEvent::class, $event, $ruleMatcher); + } + + public function testOnEventIgnoresUnsupportedEvents(): void { + $ruleMatcher = $this->createMock(IRuleMatcher::class); + $ruleMatcher->expects($this->never())->method('getFlows'); + $this->client->expects($this->never())->method('post'); + + $this->operation->onEvent('unsupported', new Event(), $ruleMatcher); + } +} diff --git a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php new file mode 100644 index 0000000..625c842 --- /dev/null +++ b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php @@ -0,0 +1,239 @@ +userSession = $this->createMock(IUserSession::class); + $this->fieldDefinitionService = $this->createMock(FieldDefinitionService::class); + $this->fieldValueMapper = $this->createMock(FieldValueMapper::class); + $this->workflowSubjectContext = new ProfileFieldValueSubjectContext(); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t') + ->willReturnCallback(static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters)); + + $this->check = new UserProfileFieldCheck( + $this->userSession, + $l10n, + $this->fieldDefinitionService, + new FieldValueService($this->fieldValueMapper, $this->createMock(IEventDispatcher::class)), + $this->workflowSubjectContext, + ); + } + + public function testSupportedEntitiesIsUniversal(): void { + $this->assertSame([], $this->check->supportedEntities()); + } + + public function testIsAvailableOnlyForAdminScope(): void { + $this->assertTrue($this->check->isAvailableForScope(IManager::SCOPE_ADMIN)); + $this->assertFalse($this->check->isAvailableForScope(IManager::SCOPE_USER)); + } + + public function testValidateCheckRejectsInvalidConfigurationPayload(): void { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The workflow check configuration is invalid'); + + $this->check->validateCheck('is', '{not-json'); + } + + public function testValidateCheckRejectsUnknownFieldKey(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('unknown_field') + ->willReturn(null); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The selected profile field does not exist'); + + $this->check->validateCheck('is', $this->encodeConfig('unknown_field', 'LATAM')); + } + + public function testValidateCheckRejectsContainsForNumberField(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('score') + ->willReturn($this->buildDefinition(7, 'score', FieldType::NUMBER->value)); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The selected operator is not supported for this profile field'); + + $this->check->validateCheck('contains', $this->encodeConfig('score', '9')); + } + + public function testExecuteCheckMatchesTextContainsCaseInsensitive(): void { + $definition = $this->buildDefinition(7, 'region', FieldType::TEXT->value); + $value = $this->buildStoredValue(7, 'alice', '{"value":"Ops LATAM"}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('region') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'alice') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertTrue($this->check->executeCheck('contains', $this->encodeConfig('region', 'latam'))); + } + + public function testExecuteCheckMatchesNumericComparison(): void { + $definition = $this->buildDefinition(7, 'score', FieldType::NUMBER->value); + $value = $this->buildStoredValue(7, 'alice', '{"value":9.5}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('score') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'alice') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertTrue($this->check->executeCheck('greater', $this->encodeConfig('score', '9'))); + } + + public function testExecuteCheckTreatsMissingValueAsNotSet(): void { + $definition = $this->buildDefinition(7, 'region', FieldType::TEXT->value); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('region') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'alice') + ->willReturn(null); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertTrue($this->check->executeCheck('!is-set', $this->encodeConfig('region', null))); + } + + public function testExecuteCheckReturnsFalseWithoutAuthenticatedUser(): void { + $this->userSession->method('getUser')->willReturn(null); + + $this->assertFalse($this->check->executeCheck('is', $this->encodeConfig('region', 'LATAM'))); + } + + public function testExecuteCheckUsesEntitySubjectUserWhenAvailable(): void { + $definition = $this->buildDefinition(7, 'region', FieldType::TEXT->value); + $value = $this->buildStoredValue(7, 'target-user', '{"value":"LATAM"}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('region') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'target-user') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('admin')); + + $entityL10n = $this->createMock(IL10N::class); + $entityL10n->method('t')->willReturnArgument(0); + $entityUrlGenerator = $this->createMock(IURLGenerator::class); + $entity = new ProfileFieldValueEntity($entityL10n, $entityUrlGenerator, $this->workflowSubjectContext); + $entity->prepareRuleMatcher($this->createMock(\OCP\WorkflowEngine\IRuleMatcher::class), 'workflow-event', new \OCA\ProfileFields\Workflow\Event\ProfileFieldValueUpdatedEvent(new ProfileFieldValueWorkflowSubject( + userUid: 'target-user', + actorUid: 'admin', + fieldDefinition: $definition, + currentValue: 'LATAM', + previousValue: 'EMEA', + currentVisibility: 'users', + previousVisibility: 'users', + ))); + + $this->assertTrue($this->check->executeCheck('is', $this->encodeConfig('region', 'LATAM'))); + } + + private function buildDefinition(int $id, string $fieldKey, string $type): FieldDefinition { + $definition = new FieldDefinition(); + $definition->setId($id); + $definition->setFieldKey($fieldKey); + $definition->setLabel(ucfirst($fieldKey)); + $definition->setType($type); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility('users'); + $definition->setSortOrder(1); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime('2026-03-10T10:00:00+00:00')); + $definition->setUpdatedAt(new \DateTime('2026-03-10T10:00:00+00:00')); + + return $definition; + } + + private function buildStoredValue(int $fieldDefinitionId, string $userUid, string $valueJson): FieldValue { + $value = new FieldValue(); + $value->setId(42); + $value->setFieldDefinitionId($fieldDefinitionId); + $value->setUserUid($userUid); + $value->setValueJson($valueJson); + $value->setCurrentVisibility('users'); + $value->setUpdatedByUid('admin'); + $value->setUpdatedAt(new \DateTime('2026-03-10T10:00:00+00:00')); + + return $value; + } + + private function buildUser(string $uid): IUser&MockObject { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + + return $user; + } + + private function encodeConfig(string $fieldKey, string|int|float|null $value): string { + try { + return json_encode([ + 'field_key' => $fieldKey, + 'value' => $value, + ], JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new InvalidArgumentException('Failed to encode test configuration', 0, $exception); + } + } +} diff --git a/vite.config.js b/vite.config.js index 649ade3..ce555a4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,5 +6,6 @@ import { createAppConfig } from '@nextcloud/vite-config' export default createAppConfig({ 'settings-admin': 'src/settings-admin.ts', 'settings-personal': 'src/settings-personal.ts', + workflow: 'src/workflow.ts', 'user-management-action': 'src/user-management-action.ts', })