From 7983248c58726ff5a4f76656aded90c7e20f8ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20D=C4=99bi=C5=84ski?= Date: Tue, 25 Aug 2026 09:42:11 +0200 Subject: [PATCH 1/4] IBX-9277: Fixed error when translating User content with non-translatable fields --- phpstan-baseline.neon | 6 - src/bundle/Controller/UserController.php | 4 +- src/bundle/Resources/config/services.yaml | 7 + src/lib/Data/Mapper/UserUpdateMapper.php | 3 +- .../UserAccountFieldValueFormMapper.php | 23 ++- .../EventSubscriber/UserFieldsSubscriber.php | 11 +- .../User/UserTranslationSecureListener.php | 66 +++++++++ .../User/UserUpdateFormProcessor.php | 6 + .../UserTranslationSecureListenerTest.php | 87 ++++++++++++ ...untFieldValueFormMapperTransformerTest.php | 131 ++++++++++++++++++ .../User/UserUpdateFormProcessorTest.php | 111 +++++++++++++++ 11 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 src/lib/Form/Processor/User/UserTranslationSecureListener.php create mode 100644 tests/integration/UserTranslationSecureListenerTest.php create mode 100644 tests/lib/FieldType/Mapper/UserAccountFieldValueFormMapperTransformerTest.php create mode 100644 tests/lib/Form/Processor/User/UserUpdateFormProcessorTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 620361bf..f0acc7c1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1884,12 +1884,6 @@ parameters: count: 1 path: src/lib/Form/EventSubscriber/UserFieldsSubscriber.php - - - message: '#^Method Ibexa\\ContentForms\\Form\\EventSubscriber\\UserFieldsSubscriber\:\:handleUserUpdateData\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/lib/Form/EventSubscriber/UserFieldsSubscriber.php - - message: '#^Method Ibexa\\ContentForms\\Form\\EventSubscriber\\UserFieldsSubscriber\:\:handleUserUpdateData\(\) has parameter \$languageCode with no type specified\.$#' identifier: missingType.parameter diff --git a/src/bundle/Controller/UserController.php b/src/bundle/Controller/UserController.php index 5755588b..d28d382d 100644 --- a/src/bundle/Controller/UserController.php +++ b/src/bundle/Controller/UserController.php @@ -168,7 +168,7 @@ public function editAction( string $language, Request $request ) { - $user = $this->userService->loadUser($contentId); + $user = $this->userService->loadUser($contentId, [$language]); if (!$this->permissionResolver->canUser('content', 'edit', $user)) { throw new CoreUnauthorizedException('content', 'edit', ['userId' => $contentId]); } @@ -199,7 +199,7 @@ public function editAction( $userUpdate, [ 'location' => $location, - 'content' => $this->contentService->loadContent($contentId), + 'content' => $this->contentService->loadContent($contentId, [$language]), 'languageCode' => $language, 'mainLanguageCode' => $user->contentInfo->mainLanguageCode, 'struct' => $userUpdate, diff --git a/src/bundle/Resources/config/services.yaml b/src/bundle/Resources/config/services.yaml index 5f71dba6..335d78d7 100644 --- a/src/bundle/Resources/config/services.yaml +++ b/src/bundle/Resources/config/services.yaml @@ -81,6 +81,13 @@ services: tags: - { name: kernel.event_subscriber } + Ibexa\ContentForms\Form\Processor\User\UserTranslationSecureListener: + arguments: + - '@ibexa.api.service.user' + - '@ibexa.api.service.content' + tags: + - { name: kernel.event_subscriber } + Ibexa\ContentForms\Form\Processor\SystemUrlRedirectProcessor: autowire: true autoconfigure: true diff --git a/src/lib/Data/Mapper/UserUpdateMapper.php b/src/lib/Data/Mapper/UserUpdateMapper.php index 5b9f7784..91cdf4f7 100644 --- a/src/lib/Data/Mapper/UserUpdateMapper.php +++ b/src/lib/Data/Mapper/UserUpdateMapper.php @@ -50,8 +50,9 @@ public function mapToFormData(User $user, ContentType $contentType, array $param $filter = $params['filter']; $fields = $user->getFieldsByLanguage($params['languageCode']); + $mainLanguageCode = $user->contentInfo->mainLanguageCode; foreach ($contentType->fieldDefinitions as $fieldDef) { - $field = $fields[$fieldDef->identifier]; + $field = $fields[$fieldDef->identifier] ?? $user->getField($fieldDef->identifier, $mainLanguageCode); if (is_callable($filter) && !($filter)($field)) { continue; diff --git a/src/lib/FieldType/Mapper/UserAccountFieldValueFormMapper.php b/src/lib/FieldType/Mapper/UserAccountFieldValueFormMapper.php index 0bc1ed9f..737e2ba1 100644 --- a/src/lib/FieldType/Mapper/UserAccountFieldValueFormMapper.php +++ b/src/lib/FieldType/Mapper/UserAccountFieldValueFormMapper.php @@ -8,8 +8,9 @@ namespace Ibexa\ContentForms\FieldType\Mapper; -use Ibexa\ContentForms\Data\ContentTranslationData; use Ibexa\ContentForms\Data\User\UserAccountFieldData; +use Ibexa\ContentForms\Data\User\UserCreateData; +use Ibexa\ContentForms\Data\User\UserUpdateData; use Ibexa\ContentForms\Form\Type\FieldType\UserAccountFieldType; use Ibexa\Contracts\ContentForms\Data\Content\FieldData; use Ibexa\Contracts\ContentForms\FieldType\FieldValueFormMapperInterface; @@ -41,12 +42,18 @@ public function mapFieldValueForm(FormInterface $fieldForm, FieldData $data) $formConfig = $fieldForm->getConfig(); $rootForm = $fieldForm->getRoot()->getRoot(); $formIntent = $rootForm->getConfig()->getOption('intent'); - $isTranslation = $rootForm->getData() instanceof ContentTranslationData; + $rootData = $rootForm->getData(); + $isTranslation = !$rootData instanceof UserCreateData && !$rootData instanceof UserUpdateData; + $isNonMainLanguageTranslation = $isTranslation + && !$fieldDefinition->isTranslatable + && $formConfig->getOption('languageCode') !== $formConfig->getOption('mainLanguageCode'); + $formBuilder = $formConfig->getFormFactory()->createBuilder() ->create('value', UserAccountFieldType::class, [ 'required' => true, 'label' => $fieldDefinition->getName(), 'intent' => $formIntent, + 'disabled' => $isNonMainLanguageTranslation, ]); if ($isTranslation) { @@ -82,13 +89,17 @@ public function configureOptions(OptionsResolver $resolver) */ public function getModelTransformerForTranslation(FieldDefinition $fieldDefinition): CallbackTransformer { + /** @var \Ibexa\Core\FieldType\User\Value $currentValue */ + $currentValue = clone $fieldDefinition->defaultValue; + return new CallbackTransformer( - static function (ApiUserValue $data) { + static function (ApiUserValue $data) use (&$currentValue) { + $currentValue = $data; + return new UserAccountFieldData($data->login, null, $data->email, $data->enabled); }, - static function (UserAccountFieldData $submittedData) use ($fieldDefinition) { - /** @var \Ibexa\Core\FieldType\User\Value $userValue */ - $userValue = clone $fieldDefinition->defaultValue; + static function (UserAccountFieldData $submittedData) use (&$currentValue) { + $userValue = clone $currentValue; $userValue->login = $submittedData->username; $userValue->email = $submittedData->email; diff --git a/src/lib/Form/EventSubscriber/UserFieldsSubscriber.php b/src/lib/Form/EventSubscriber/UserFieldsSubscriber.php index 5909e7b0..9554d678 100644 --- a/src/lib/Form/EventSubscriber/UserFieldsSubscriber.php +++ b/src/lib/Form/EventSubscriber/UserFieldsSubscriber.php @@ -10,7 +10,6 @@ use Ibexa\ContentForms\Data\User\UserCreateData; use Ibexa\ContentForms\Data\User\UserUpdateData; -use Ibexa\Core\FieldType\User\Value; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -84,7 +83,7 @@ private function handleUserCreateData(UserCreateData $data) * @param \Ibexa\ContentForms\Data\User\UserUpdateData $data * @param $languageCode */ - private function handleUserUpdateData(UserUpdateData $data, $languageCode) + private function handleUserUpdateData(UserUpdateData $data, $languageCode): void { foreach ($data->fieldsData as $fieldData) { if ('ezuser' !== $fieldData->getFieldTypeIdentifier()) { @@ -97,8 +96,14 @@ private function handleUserUpdateData(UserUpdateData $data, $languageCode) $data->password = $userAccountFieldData->password; $data->enabled = $userAccountFieldData->enabled; + // The "user_account" field is non-translatable and therefore + // never stored under a language other than the main one: fall + // back to it when editing/translating in a different language. + $userField = $data->user->getField($fieldData->field->fieldDefIdentifier, $languageCode) + ?? $data->user->getField($fieldData->field->fieldDefIdentifier, $data->user->contentInfo->mainLanguageCode); + /** @var \Ibexa\Core\FieldType\User\Value $userValue */ - $userValue = clone $data->user->getField($fieldData->field->fieldDefIdentifier, $languageCode)->value; + $userValue = clone $userField->value; $userValue->email = $data->email; $userValue->enabled = $data->enabled; $userValue->plainPassword = $data->password; diff --git a/src/lib/Form/Processor/User/UserTranslationSecureListener.php b/src/lib/Form/Processor/User/UserTranslationSecureListener.php new file mode 100644 index 00000000..5a1aa02f --- /dev/null +++ b/src/lib/Form/Processor/User/UserTranslationSecureListener.php @@ -0,0 +1,66 @@ +userService = $userService; + $this->contentService = $contentService; + } + + public static function getSubscribedEvents(): array + { + return [ + ContentFormEvents::CONTENT_PUBLISH => ['onPublish', 5], + ]; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentFieldValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + */ + public function onPublish(FormActionEvent $event): void + { + $content = $event->getPayload('content'); + if (null === $content || !$this->userService->isUser($content)) { + return; + } + + $languageCode = $event->getForm()->getConfig()->getOption('languageCode'); + if ($languageCode === $content->contentInfo->mainLanguageCode) { + return; + } + + $user = $this->userService->loadUser($content->id, [$languageCode]); + $userStruct = $this->userService->newUserUpdateStruct(); + $userStruct->contentUpdateStruct = $this->contentService->newContentUpdateStruct(); + $userStruct->contentUpdateStruct->initialLanguageCode = $languageCode; + + $this->userService->updateUser($user, $userStruct); + } +} diff --git a/src/lib/Form/Processor/User/UserUpdateFormProcessor.php b/src/lib/Form/Processor/User/UserUpdateFormProcessor.php index 4dc3b251..7c5c76a0 100644 --- a/src/lib/Form/Processor/User/UserUpdateFormProcessor.php +++ b/src/lib/Form/Processor/User/UserUpdateFormProcessor.php @@ -80,8 +80,14 @@ public function processUpdate(FormActionEvent $event) private function setContentFields(UserUpdateData $data, string $languageCode): void { $data->contentUpdateStruct = $this->contentService->newContentUpdateStruct(); + $data->contentUpdateStruct->initialLanguageCode = $languageCode; + $mainLanguageCode = $data->user->contentInfo->mainLanguageCode; foreach ($data->fieldsData as $fieldDefIdentifier => $fieldData) { + if ($mainLanguageCode !== $languageCode && !$fieldData->fieldDefinition->isTranslatable) { + continue; + } + $data->contentUpdateStruct->setField($fieldDefIdentifier, $fieldData->value, $languageCode); } } diff --git a/tests/integration/UserTranslationSecureListenerTest.php b/tests/integration/UserTranslationSecureListenerTest.php new file mode 100644 index 00000000..039a42a2 --- /dev/null +++ b/tests/integration/UserTranslationSecureListenerTest.php @@ -0,0 +1,87 @@ +createUser(self::LOGIN, 'John', 'Doe'); + $mainLanguageCode = $user->contentInfo->mainLanguageCode; + + $userContentType = $contentTypeService->loadContentTypeByIdentifier('user'); + $translatableFieldDefinitions = []; + foreach ($userContentType->fieldDefinitions as $fieldDefinition) { + if ( + $fieldDefinition->identifier !== 'user_account' + && $fieldDefinition->isTranslatable + && \in_array($fieldDefinition->fieldTypeIdentifier, ['ezstring', 'eztext'], true) + ) { + $translatableFieldDefinitions[] = $fieldDefinition; + } + } + self::assertNotEmpty($translatableFieldDefinitions, 'Expected the "user" content type to have translatable fields besides "user_account".'); + + $draft = $contentService->createContentDraft($user->contentInfo); + $updateStruct = $contentService->newContentUpdateStruct(); + $updateStruct->initialLanguageCode = self::NEW_LANGUAGE; + foreach ($translatableFieldDefinitions as $fieldDefinition) { + $updateStruct->setField($fieldDefinition->identifier, 'German value', self::NEW_LANGUAGE); + } + $updatedDraft = $contentService->updateContent($draft->versionInfo, $updateStruct); + $publishedContent = $contentService->publishVersion($updatedDraft->versionInfo); + + $form = new Form( + (new FormConfigBuilder('form', null, new EventDispatcher(), [ + 'languageCode' => self::NEW_LANGUAGE, + ]))->getFormConfig() + ); + $listener = new UserTranslationSecureListener($userService, $contentService); + $listener->onPublish(new \Ibexa\ContentForms\Event\FormActionEvent( + $form, + null, + 'publish', + [], + ['content' => $publishedContent] + )); + + $userAfterTranslation = $userService->loadUserByLogin(self::LOGIN); + self::assertSame(self::LOGIN, $userAfterTranslation->login); + self::assertSame(self::EMAIL, $userAfterTranslation->email); + self::assertTrue($userAfterTranslation->enabled); + + $contentService->deleteTranslation($publishedContent->contentInfo, self::NEW_LANGUAGE); + $userAfterDeletion = $userService->loadUserByLogin(self::LOGIN); + + self::assertSame(self::LOGIN, $userAfterDeletion->login); + self::assertSame(self::EMAIL, $userAfterDeletion->email); + self::assertTrue($userAfterDeletion->enabled); + self::assertSame($userAfterTranslation->passwordHash, $userAfterDeletion->passwordHash); + + $contentAfterDeletion = $contentService->loadContent($publishedContent->id, [$mainLanguageCode]); + self::assertSame( + [$mainLanguageCode], + $contentAfterDeletion->versionInfo->languageCodes + ); + } +} diff --git a/tests/lib/FieldType/Mapper/UserAccountFieldValueFormMapperTransformerTest.php b/tests/lib/FieldType/Mapper/UserAccountFieldValueFormMapperTransformerTest.php new file mode 100644 index 00000000..bbb5e511 --- /dev/null +++ b/tests/lib/FieldType/Mapper/UserAccountFieldValueFormMapperTransformerTest.php @@ -0,0 +1,131 @@ + [], + 'isTranslatable' => false, + 'defaultValue' => new ApiUserValue(), + ]); + + $data = $this->getMockBuilder(FieldData::class) + ->disableOriginalConstructor() + ->getMock(); + $data->expects($this->once()) + ->method('__get') + ->with('fieldDefinition') + ->willReturn($fieldDefinition); + + $config = $this->getMockBuilder(FormConfigInterface::class)->getMock(); + $config->method('getOption') + ->willReturnMap([ + ['languageCode', null, 'ger-DE'], + ['mainLanguageCode', null, 'eng-GB'], + ]); + + $formFactory = $this->getMockBuilder(FormFactoryInterface::class) + ->setMethods(['addModelTransformer', 'setAutoInitialize', 'getForm']) + ->getMockForAbstractClass(); + $formFactory->method('createBuilder')->willReturn($formFactory); + $formFactory->method('addModelTransformer')->willReturn($formFactory); + $formFactory->method('setAutoInitialize')->willReturn($formFactory); + $formFactory->expects($this->once()) + ->method('create') + ->with( + $this->anything(), + $this->anything(), + $this->callback(static function (array $options): bool { + return true === $options['disabled']; + }) + ) + ->willReturn($formFactory); + + $config->method('getFormFactory')->willReturn($formFactory); + + $fieldForm = $this->getMockBuilder(FormInterface::class)->getMock(); + $fieldForm->method('getConfig')->willReturn($config); + + $rootData = new ContentTranslationData(); + $formRoot = $this->getMockBuilder(FormInterface::class)->getMock(); + $formRoot->method('getData')->willReturn($rootData); + $rootConfig = $this->getMockBuilder(FormConfigInterface::class)->getMock(); + $rootConfig->method('getOption')->with('intent')->willReturn('update'); + $formRoot->method('getConfig')->willReturn($rootConfig); + + $userEditForm = $this->getMockBuilder(FormInterface::class)->getMock(); + $userEditForm->method('getRoot')->willReturn($formRoot); + $fieldForm->method('getRoot')->willReturn($userEditForm); + + $mapper->mapFieldValueForm($fieldForm, $data); + } + + public function testModelTransformerForTranslationClonesLastTransformedValueOnReverseTransform(): void + { + $mapper = new UserAccountFieldValueFormMapper(); + + $defaultValue = new ApiUserValue(['login' => 'default-login', 'email' => 'default@example.com']); + $fieldDefinition = new FieldDefinition(['names' => [], 'defaultValue' => $defaultValue]); + + $transformer = $mapper->getModelTransformerForTranslation($fieldDefinition); + + $currentValue = new ApiUserValue([ + 'login' => 'current-login', + 'email' => 'current@example.com', + 'enabled' => true, + 'maxLogin' => 5, + ]); + + $transformer->transform($currentValue); + + $submittedData = new UserAccountFieldData('new-login', '', 'new@example.com', false); + $result = $transformer->reverseTransform($submittedData); + + self::assertNotSame($currentValue, $result); + self::assertSame('new-login', $result->login); + self::assertSame('new@example.com', $result->email); + self::assertFalse($result->enabled); + self::assertSame(5, $result->maxLogin); + } + + public function testModelTransformerForTranslationFallsBackToFieldDefinitionDefaultValue(): void + { + $mapper = new UserAccountFieldValueFormMapper(); + + $defaultValue = new ApiUserValue(['login' => 'default-login', 'maxLogin' => 3]); + $fieldDefinition = new FieldDefinition(['names' => [], 'defaultValue' => $defaultValue]); + + $transformer = $mapper->getModelTransformerForTranslation($fieldDefinition); + + $submittedData = new UserAccountFieldData('new-login', '', 'new@example.com', true); + $result = $transformer->reverseTransform($submittedData); + + self::assertNotSame($defaultValue, $result); + self::assertSame('new-login', $result->login); + self::assertSame('new@example.com', $result->email); + self::assertTrue($result->enabled); + self::assertSame(3, $result->maxLogin); + } +} diff --git a/tests/lib/Form/Processor/User/UserUpdateFormProcessorTest.php b/tests/lib/Form/Processor/User/UserUpdateFormProcessorTest.php new file mode 100644 index 00000000..a5c06630 --- /dev/null +++ b/tests/lib/Form/Processor/User/UserUpdateFormProcessorTest.php @@ -0,0 +1,111 @@ + 42, 'mainLanguageCode' => $mainLanguageCode]); + $versionInfo = new VersionInfo(['contentInfo' => $contentInfo]); + $content = new Content(['versionInfo' => $versionInfo]); + + return new User(['content' => $content]); + } + + private function createField(string $identifier, bool $isTranslatable, string $value): FieldData + { + $field = $this->getMockBuilder(FieldData::class) + ->disableOriginalConstructor() + ->getMock(); + $field->value = $value; + $field->method('__get') + ->with('fieldDefinition') + ->willReturn(new FieldDefinition(['identifier' => $identifier, 'isTranslatable' => $isTranslatable])); + + return $field; + } + + private function callSetContentFields(UserUpdateFormProcessor $processor, UserUpdateData $data, string $languageCode): void + { + $method = new ReflectionMethod(UserUpdateFormProcessor::class, 'setContentFields'); + $method->setAccessible(true); + $method->invoke($processor, $data, $languageCode); + } + + public function testNonTranslatableFieldIsSkippedOnNonMainLanguageUpdate(): void + { + $data = new UserUpdateData(); + $data->user = $this->createUser('eng-GB'); + $data->addFieldData($this->createField('title', true, 'translatable-value')); + $data->addFieldData($this->createField('user_account', false, 'non-translatable-value')); + + $contentUpdateStruct = $this->getMockBuilder(ContentUpdateStruct::class)->getMock(); + $contentUpdateStruct->expects($this->once()) + ->method('setField') + ->with('title', 'translatable-value', 'ger-DE'); + + $contentService = $this->createMock(ContentService::class); + $contentService->method('newContentUpdateStruct')->willReturn($contentUpdateStruct); + + $processor = new UserUpdateFormProcessor( + $this->createMock(UserService::class), + $contentService, + $this->createMock(UrlGeneratorInterface::class) + ); + + $this->callSetContentFields($processor, $data, 'ger-DE'); + + self::assertSame('ger-DE', $contentUpdateStruct->initialLanguageCode); + } + + public function testAllFieldsAreUpdatedOnMainLanguageUpdate(): void + { + $data = new UserUpdateData(); + $data->user = $this->createUser('eng-GB'); + $data->addFieldData($this->createField('title', true, 'translatable-value')); + $data->addFieldData($this->createField('user_account', false, 'non-translatable-value')); + + $contentUpdateStruct = $this->getMockBuilder(ContentUpdateStruct::class)->getMock(); + $contentUpdateStruct->expects($this->exactly(2)) + ->method('setField') + ->withConsecutive( + ['title', 'translatable-value', 'eng-GB'], + ['user_account', 'non-translatable-value', 'eng-GB'] + ); + + $contentService = $this->createMock(ContentService::class); + $contentService->method('newContentUpdateStruct')->willReturn($contentUpdateStruct); + + $processor = new UserUpdateFormProcessor( + $this->createMock(UserService::class), + $contentService, + $this->createMock(UrlGeneratorInterface::class) + ); + + $this->callSetContentFields($processor, $data, 'eng-GB'); + + self::assertSame('eng-GB', $contentUpdateStruct->initialLanguageCode); + } +} From acb99afb393c2fd7fde1bbeddc37ce5949ecf11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20D=C4=99bi=C5=84ski?= Date: Tue, 15 Sep 2026 10:28:00 +0200 Subject: [PATCH 2/4] Corrected test --- .../Form/Processor/User/UserTranslationSecureListener.php | 2 +- tests/bootstrap.php | 8 +------- tests/integration/UserTranslationSecureListenerTest.php | 7 ++++--- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/lib/Form/Processor/User/UserTranslationSecureListener.php b/src/lib/Form/Processor/User/UserTranslationSecureListener.php index 5a1aa02f..bf0092f2 100644 --- a/src/lib/Form/Processor/User/UserTranslationSecureListener.php +++ b/src/lib/Form/Processor/User/UserTranslationSecureListener.php @@ -24,7 +24,7 @@ class UserTranslationSecureListener implements EventSubscriberInterface public function __construct( UserService $userService, - ContentService $contentService, + ContentService $contentService ) { $this->userService = $userService; $this->contentService = $contentService; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a50889fe..49b01a86 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,15 +7,9 @@ declare(strict_types=1); use Ibexa\Contracts\Test\Core\Bootstrapper\Bootstrapper; -use Ibexa\Contracts\Test\Core\Bootstrapper\DatabaseSchemaHook; -use Ibexa\Contracts\Test\Core\Bootstrapper\FixtureHook; require_once dirname(__DIR__) . '/vendor/autoload.php'; chdir(dirname(__DIR__)); -(new Bootstrapper())->bootstrap(null, [ - Bootstrapper::class => [Bootstrapper::OPTION_PREPARE_DATABASE => false], - DatabaseSchemaHook::class => [DatabaseSchemaHook::OPTION_LOAD_SCHEMA => false], - FixtureHook::class => [FixtureHook::OPTION_LOAD_FIXTURES => false], -]); +(new Bootstrapper())->bootstrap(); diff --git a/tests/integration/UserTranslationSecureListenerTest.php b/tests/integration/UserTranslationSecureListenerTest.php index 039a42a2..336ec8c1 100644 --- a/tests/integration/UserTranslationSecureListenerTest.php +++ b/tests/integration/UserTranslationSecureListenerTest.php @@ -22,9 +22,10 @@ final class UserTranslationSecureListenerTest extends RepositoryTestCase public function testTranslationFlowDoesNotRemoveUserData(): void { - $userService = self::getUserService(); - $contentService = self::getContentService(); - $contentTypeService = self::getContentTypeService(); + $ibexaTestCore = $this->getIbexaTestCore(); + $userService = $ibexaTestCore->getUserService(); + $contentService = $ibexaTestCore->getContentService(); + $contentTypeService = $ibexaTestCore->getContentTypeService(); $user = $this->createUser(self::LOGIN, 'John', 'Doe'); $mainLanguageCode = $user->contentInfo->mainLanguageCode; From 58753db9a6b7c02579ccfd95c534373b40cf3e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20D=C4=99bi=C5=84ski?= Date: Tue, 15 Sep 2026 14:24:49 +0200 Subject: [PATCH 3/4] Added CONTENT_PUBLISH_AND_EDIT for UserTranslationSecureListener --- .../User/UserTranslationSecureListener.php | 7 +-- .../UserTranslationSecureListenerTest.php | 60 +++++++++++++------ 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/lib/Form/Processor/User/UserTranslationSecureListener.php b/src/lib/Form/Processor/User/UserTranslationSecureListener.php index bf0092f2..52aeae4d 100644 --- a/src/lib/Form/Processor/User/UserTranslationSecureListener.php +++ b/src/lib/Form/Processor/User/UserTranslationSecureListener.php @@ -16,11 +16,9 @@ class UserTranslationSecureListener implements EventSubscriberInterface { - /** @var \Ibexa\Contracts\Core\Repository\UserService */ - private $userService; + private UserService $userService; - /** @var \Ibexa\Contracts\Core\Repository\ContentService */ - private $contentService; + private ContentService $contentService; public function __construct( UserService $userService, @@ -34,6 +32,7 @@ public static function getSubscribedEvents(): array { return [ ContentFormEvents::CONTENT_PUBLISH => ['onPublish', 5], + ContentFormEvents::CONTENT_PUBLISH_AND_EDIT => ['onPublish', 5], ]; } diff --git a/tests/integration/UserTranslationSecureListenerTest.php b/tests/integration/UserTranslationSecureListenerTest.php index 336ec8c1..2b0984c0 100644 --- a/tests/integration/UserTranslationSecureListenerTest.php +++ b/tests/integration/UserTranslationSecureListenerTest.php @@ -8,6 +8,8 @@ namespace Ibexa\Tests\Integration\ContentForms; +use Ibexa\ContentForms\Event\ContentFormEvents; +use Ibexa\ContentForms\Event\FormActionEvent; use Ibexa\ContentForms\Form\Processor\User\UserTranslationSecureListener; use Ibexa\Tests\Integration\Core\RepositoryTestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -16,18 +18,22 @@ final class UserTranslationSecureListenerTest extends RepositoryTestCase { - private const LOGIN = 'jdoe'; - private const EMAIL = 'jdoe@mail.invalid'; private const NEW_LANGUAGE = 'ger-DE'; - public function testTranslationFlowDoesNotRemoveUserData(): void + /** + * @dataProvider providePublishEventNames + */ + public function testTranslationFlowDoesNotRemoveUserData(string $eventName): void { + $login = 'jdoe_' . md5($eventName); + $email = $login . '@mail.invalid'; + $ibexaTestCore = $this->getIbexaTestCore(); $userService = $ibexaTestCore->getUserService(); $contentService = $ibexaTestCore->getContentService(); $contentTypeService = $ibexaTestCore->getContentTypeService(); - $user = $this->createUser(self::LOGIN, 'John', 'Doe'); + $user = $this->createUser($login, 'John', 'Doe'); $mainLanguageCode = $user->contentInfo->mainLanguageCode; $userContentType = $contentTypeService->loadContentTypeByIdentifier('user'); @@ -57,25 +63,30 @@ public function testTranslationFlowDoesNotRemoveUserData(): void 'languageCode' => self::NEW_LANGUAGE, ]))->getFormConfig() ); - $listener = new UserTranslationSecureListener($userService, $contentService); - $listener->onPublish(new \Ibexa\ContentForms\Event\FormActionEvent( - $form, - null, - 'publish', - [], - ['content' => $publishedContent] - )); - - $userAfterTranslation = $userService->loadUserByLogin(self::LOGIN); - self::assertSame(self::LOGIN, $userAfterTranslation->login); - self::assertSame(self::EMAIL, $userAfterTranslation->email); + + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber(new UserTranslationSecureListener($userService, $contentService)); + $eventDispatcher->dispatch( + new FormActionEvent( + $form, + null, + 'publish', + [], + ['content' => $publishedContent] + ), + $eventName + ); + + $userAfterTranslation = $userService->loadUserByLogin($login); + self::assertSame($login, $userAfterTranslation->login); + self::assertSame($email, $userAfterTranslation->email); self::assertTrue($userAfterTranslation->enabled); $contentService->deleteTranslation($publishedContent->contentInfo, self::NEW_LANGUAGE); - $userAfterDeletion = $userService->loadUserByLogin(self::LOGIN); + $userAfterDeletion = $userService->loadUserByLogin($login); - self::assertSame(self::LOGIN, $userAfterDeletion->login); - self::assertSame(self::EMAIL, $userAfterDeletion->email); + self::assertSame($login, $userAfterDeletion->login); + self::assertSame($email, $userAfterDeletion->email); self::assertTrue($userAfterDeletion->enabled); self::assertSame($userAfterTranslation->passwordHash, $userAfterDeletion->passwordHash); @@ -85,4 +96,15 @@ public function testTranslationFlowDoesNotRemoveUserData(): void $contentAfterDeletion->versionInfo->languageCodes ); } + + /** + * @return iterable + */ + public static function providePublishEventNames(): iterable + { + return [ + 'publish' => [ContentFormEvents::CONTENT_PUBLISH], + 'publish and edit' => [ContentFormEvents::CONTENT_PUBLISH_AND_EDIT], + ]; + } } From 72d9329f7282d22596a233ef2555b773c7341509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20D=C4=99bi=C5=84ski?= Date: Wed, 16 Sep 2026 10:55:25 +0200 Subject: [PATCH 4/4] Added tests, corrected CS --- .../EventSubscriber/UserFieldsSubscriber.php | 5 +- .../User/UserTranslationSecureListener.php | 17 ++- .../UserTranslationSecureListenerTest.php | 8 +- ...untFieldValueFormMapperTransformerTest.php | 140 ++++++++++++------ .../UserFieldsSubscriberTest.php | 126 ++++++++++++++++ .../UserTranslationSecureListenerTest.php | 89 +++++++++++ .../User/UserUpdateFormProcessorTest.php | 83 ++++++----- 7 files changed, 378 insertions(+), 90 deletions(-) create mode 100644 tests/lib/Form/EventSubscriber/UserFieldsSubscriberTest.php create mode 100644 tests/lib/Form/Processor/User/UserTranslationSecureListenerTest.php diff --git a/src/lib/Form/EventSubscriber/UserFieldsSubscriber.php b/src/lib/Form/EventSubscriber/UserFieldsSubscriber.php index 9554d678..6cb130e6 100644 --- a/src/lib/Form/EventSubscriber/UserFieldsSubscriber.php +++ b/src/lib/Form/EventSubscriber/UserFieldsSubscriber.php @@ -95,12 +95,13 @@ private function handleUserUpdateData(UserUpdateData $data, $languageCode): void $data->email = $userAccountFieldData->email; $data->password = $userAccountFieldData->password; $data->enabled = $userAccountFieldData->enabled; + $fieldIdentifier = $fieldData->field->fieldDefIdentifier; // The "user_account" field is non-translatable and therefore // never stored under a language other than the main one: fall // back to it when editing/translating in a different language. - $userField = $data->user->getField($fieldData->field->fieldDefIdentifier, $languageCode) - ?? $data->user->getField($fieldData->field->fieldDefIdentifier, $data->user->contentInfo->mainLanguageCode); + $userField = $data->user->getField($fieldIdentifier, $languageCode) + ?? $data->user->getField($fieldIdentifier, $data->user->contentInfo->mainLanguageCode); /** @var \Ibexa\Core\FieldType\User\Value $userValue */ $userValue = clone $userField->value; diff --git a/src/lib/Form/Processor/User/UserTranslationSecureListener.php b/src/lib/Form/Processor/User/UserTranslationSecureListener.php index 52aeae4d..f4981e45 100644 --- a/src/lib/Form/Processor/User/UserTranslationSecureListener.php +++ b/src/lib/Form/Processor/User/UserTranslationSecureListener.php @@ -14,7 +14,22 @@ use Ibexa\Contracts\Core\Repository\UserService; use Symfony\Component\EventDispatcher\EventSubscriberInterface; -class UserTranslationSecureListener implements EventSubscriberInterface +/** + * Re-applies the User data after publishing a non-main-language translation. + * + * The "user_account" field (ezuser) is non-translatable and its form is disabled while + * translating (see UserAccountFieldValueFormMapper::mapFieldValueForm()), so it is never + * submitted as part of the translation's content update. Without this listener re-running + * UserService::updateUser() for the translated language after publish, translating User + * content throws a content field validation error / leaves the user_account field broken + * for that language. + * + * It also prevents data loss on translation removal: without re-persisting the user_account + * field for the translation's language here, ContentService::deleteTranslation() on that + * language would delete the User itself (login, password, etc.), even though the underlying + * content object survives. + */ +final class UserTranslationSecureListener implements EventSubscriberInterface { private UserService $userService; diff --git a/tests/integration/UserTranslationSecureListenerTest.php b/tests/integration/UserTranslationSecureListenerTest.php index 2b0984c0..1fe789e3 100644 --- a/tests/integration/UserTranslationSecureListenerTest.php +++ b/tests/integration/UserTranslationSecureListenerTest.php @@ -23,9 +23,9 @@ final class UserTranslationSecureListenerTest extends RepositoryTestCase /** * @dataProvider providePublishEventNames */ - public function testTranslationFlowDoesNotRemoveUserData(string $eventName): void + public function testTranslationFlowDoesNotRemoveUserData(string $eventName, string $loginSuffix): void { - $login = 'jdoe_' . md5($eventName); + $login = 'jdoe_' . $loginSuffix; $email = $login . '@mail.invalid'; $ibexaTestCore = $this->getIbexaTestCore(); @@ -103,8 +103,8 @@ public function testTranslationFlowDoesNotRemoveUserData(string $eventName): voi public static function providePublishEventNames(): iterable { return [ - 'publish' => [ContentFormEvents::CONTENT_PUBLISH], - 'publish and edit' => [ContentFormEvents::CONTENT_PUBLISH_AND_EDIT], + 'publish' => [ContentFormEvents::CONTENT_PUBLISH, 'publish'], + 'publish and edit' => [ContentFormEvents::CONTENT_PUBLISH_AND_EDIT, 'publish_and_edit'], ]; } } diff --git a/tests/lib/FieldType/Mapper/UserAccountFieldValueFormMapperTransformerTest.php b/tests/lib/FieldType/Mapper/UserAccountFieldValueFormMapperTransformerTest.php index bbb5e511..9b7907c0 100644 --- a/tests/lib/FieldType/Mapper/UserAccountFieldValueFormMapperTransformerTest.php +++ b/tests/lib/FieldType/Mapper/UserAccountFieldValueFormMapperTransformerTest.php @@ -15,11 +15,12 @@ use Ibexa\Core\FieldType\User\Value as ApiUserValue; use Ibexa\Core\Repository\Values\ContentType\FieldDefinition; use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\FormConfigInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; -class UserAccountFieldValueFormMapperTransformerTest extends TestCase +final class UserAccountFieldValueFormMapperTransformerTest extends TestCase { public function testMapFieldValueFormDisablesNonTranslatableFieldOnNonMainLanguageTranslation(): void { @@ -31,15 +32,13 @@ public function testMapFieldValueFormDisablesNonTranslatableFieldOnNonMainLangua 'defaultValue' => new ApiUserValue(), ]); - $data = $this->getMockBuilder(FieldData::class) - ->disableOriginalConstructor() - ->getMock(); - $data->expects($this->once()) + $data = $this->createMock(FieldData::class); + $data->expects(self::once()) ->method('__get') ->with('fieldDefinition') ->willReturn($fieldDefinition); - $config = $this->getMockBuilder(FormConfigInterface::class)->getMock(); + $config = $this->createStub(FormConfigInterface::class); $config->method('getOption') ->willReturnMap([ ['languageCode', null, 'ger-DE'], @@ -50,47 +49,98 @@ public function testMapFieldValueFormDisablesNonTranslatableFieldOnNonMainLangua ->setMethods(['addModelTransformer', 'setAutoInitialize', 'getForm']) ->getMockForAbstractClass(); $formFactory->method('createBuilder')->willReturn($formFactory); - $formFactory->method('addModelTransformer')->willReturn($formFactory); - $formFactory->method('setAutoInitialize')->willReturn($formFactory); - $formFactory->expects($this->once()) + $formFactory->expects(self::once()) + ->method('addModelTransformer') + ->with(self::isInstanceOf(CallbackTransformer::class)) + ->willReturn($formFactory); + $formFactory->expects(self::once()) + ->method('setAutoInitialize') + ->with(false) + ->willReturn($formFactory); + $formFactory->expects(self::once()) ->method('create') ->with( $this->anything(), $this->anything(), $this->callback(static function (array $options): bool { - return true === $options['disabled']; + return true === $options['disabled'] && 'update' === $options['intent']; }) ) ->willReturn($formFactory); + $builtForm = $this->createStub(FormInterface::class); + $formFactory->expects(self::once()) + ->method('getForm') + ->willReturn($builtForm); + $config->method('getFormFactory')->willReturn($formFactory); - $fieldForm = $this->getMockBuilder(FormInterface::class)->getMock(); + $fieldForm = $this->createMock(FormInterface::class); $fieldForm->method('getConfig')->willReturn($config); + $fieldForm->expects(self::once()) + ->method('add') + ->with($builtForm); $rootData = new ContentTranslationData(); - $formRoot = $this->getMockBuilder(FormInterface::class)->getMock(); - $formRoot->method('getData')->willReturn($rootData); - $rootConfig = $this->getMockBuilder(FormConfigInterface::class)->getMock(); - $rootConfig->method('getOption')->with('intent')->willReturn('update'); + $formRoot = $this->createMock(FormInterface::class); + $formRoot->expects(self::once()) + ->method('getData') + ->willReturn($rootData); + $rootConfig = $this->createMock(FormConfigInterface::class); + $rootConfig->expects(self::once()) + ->method('getOption') + ->with('intent') + ->willReturn('update'); $formRoot->method('getConfig')->willReturn($rootConfig); - $userEditForm = $this->getMockBuilder(FormInterface::class)->getMock(); + $userEditForm = $this->createStub(FormInterface::class); $userEditForm->method('getRoot')->willReturn($formRoot); $fieldForm->method('getRoot')->willReturn($userEditForm); $mapper->mapFieldValueForm($fieldForm, $data); } - public function testModelTransformerForTranslationClonesLastTransformedValueOnReverseTransform(): void - { + /** + * @dataProvider provideReverseTransformCases + * + * @param array{login: string, email: string, enabled: bool, maxLogin: int} $expected + */ + public function testModelTransformerForTranslationReverseTransform( + ApiUserValue $defaultValue, + ?ApiUserValue $currentValue, + UserAccountFieldData $submittedData, + ApiUserValue $baseValueForComparison, + array $expected + ): void { $mapper = new UserAccountFieldValueFormMapper(); - - $defaultValue = new ApiUserValue(['login' => 'default-login', 'email' => 'default@example.com']); $fieldDefinition = new FieldDefinition(['names' => [], 'defaultValue' => $defaultValue]); $transformer = $mapper->getModelTransformerForTranslation($fieldDefinition); + if (null !== $currentValue) { + $transformer->transform($currentValue); + } + + $result = $transformer->reverseTransform($submittedData); + + self::assertNotSame($baseValueForComparison, $result); + self::assertSame($expected['login'], $result->login); + self::assertSame($expected['email'], $result->email); + self::assertSame($expected['enabled'], $result->enabled); + self::assertSame($expected['maxLogin'], $result->maxLogin); + } + + /** + * @return iterable + */ + public function provideReverseTransformCases(): iterable + { $currentValue = new ApiUserValue([ 'login' => 'current-login', 'email' => 'current@example.com', @@ -98,34 +148,32 @@ public function testModelTransformerForTranslationClonesLastTransformedValueOnRe 'maxLogin' => 5, ]); - $transformer->transform($currentValue); - - $submittedData = new UserAccountFieldData('new-login', '', 'new@example.com', false); - $result = $transformer->reverseTransform($submittedData); - - self::assertNotSame($currentValue, $result); - self::assertSame('new-login', $result->login); - self::assertSame('new@example.com', $result->email); - self::assertFalse($result->enabled); - self::assertSame(5, $result->maxLogin); - } - - public function testModelTransformerForTranslationFallsBackToFieldDefinitionDefaultValue(): void - { - $mapper = new UserAccountFieldValueFormMapper(); + yield 'clones last transformed value on reverse transform' => [ + new ApiUserValue(['login' => 'default-login', 'email' => 'default@example.com']), + $currentValue, + new UserAccountFieldData('new-login', '', 'new@example.com', false), + $currentValue, + [ + 'login' => 'new-login', + 'email' => 'new@example.com', + 'enabled' => false, + 'maxLogin' => 5, + ], + ]; $defaultValue = new ApiUserValue(['login' => 'default-login', 'maxLogin' => 3]); - $fieldDefinition = new FieldDefinition(['names' => [], 'defaultValue' => $defaultValue]); - - $transformer = $mapper->getModelTransformerForTranslation($fieldDefinition); - - $submittedData = new UserAccountFieldData('new-login', '', 'new@example.com', true); - $result = $transformer->reverseTransform($submittedData); - self::assertNotSame($defaultValue, $result); - self::assertSame('new-login', $result->login); - self::assertSame('new@example.com', $result->email); - self::assertTrue($result->enabled); - self::assertSame(3, $result->maxLogin); + yield 'falls back to field definition default value' => [ + $defaultValue, + null, + new UserAccountFieldData('new-login', '', 'new@example.com', true), + $defaultValue, + [ + 'login' => 'new-login', + 'email' => 'new@example.com', + 'enabled' => true, + 'maxLogin' => 3, + ], + ]; } } diff --git a/tests/lib/Form/EventSubscriber/UserFieldsSubscriberTest.php b/tests/lib/Form/EventSubscriber/UserFieldsSubscriberTest.php new file mode 100644 index 00000000..57d16e84 --- /dev/null +++ b/tests/lib/Form/EventSubscriber/UserFieldsSubscriberTest.php @@ -0,0 +1,126 @@ + $fields + */ + private function createUser(string $mainLanguageCode, array $fields): User + { + $contentInfo = new ContentInfo(['mainLanguageCode' => $mainLanguageCode]); + $versionInfo = new VersionInfo(['contentInfo' => $contentInfo]); + $content = new Content(['versionInfo' => $versionInfo, 'internalFields' => $fields]); + + return new User(['content' => $content]); + } + + private function createForm(string $languageCode): Form + { + return new Form( + (new FormConfigBuilder('form', null, new EventDispatcher(), [ + 'languageCode' => $languageCode, + ]))->getFormConfig() + ); + } + + /** + * @dataProvider provideUserAccountValueResolutionCases + * + * @param list $existingFields + */ + public function testHandleUserAccountFieldResolvesBaseValueByLanguage( + array $existingFields, + string $mainLanguageCode, + string $formLanguageCode, + string $expectedBaseLogin + ): void { + $user = $this->createUser($mainLanguageCode, $existingFields); + + $data = new UserUpdateData(); + $data->user = $user; + + $fieldData = new FieldData([ + 'field' => new Field(['fieldDefIdentifier' => 'user_account']), + 'fieldDefinition' => new FieldDefinition([ + 'identifier' => 'user_account', + 'fieldTypeIdentifier' => 'ezuser', + ]), + 'value' => new UserAccountFieldData('submitted-login', 'new-password', 'new@example.com', false), + ]); + $data->addFieldData($fieldData); + + $event = new FormEvent($this->createForm($formLanguageCode), $data); + + (new UserFieldsSubscriber())->handleUserAccountField($event); + + /** @var \Ibexa\Core\FieldType\User\Value $result */ + $result = $fieldData->value; + + self::assertSame($expectedBaseLogin, $result->login); + self::assertSame('new@example.com', $result->email); + self::assertFalse($result->enabled); + self::assertSame('new-password', $result->plainPassword); + } + + /** + * @return iterable, 1: string, 2: string, 3: string}> + */ + public function provideUserAccountValueResolutionCases(): iterable + { + yield 'falls back to main language field when translation has none' => [ + [ + new Field([ + 'fieldDefIdentifier' => 'user_account', + 'languageCode' => 'eng-GB', + 'value' => new ApiUserValue(['login' => 'main-login']), + ]), + ], + 'eng-GB', + 'ger-DE', + 'main-login', + ]; + + yield 'uses the field for the requested language directly when present' => [ + [ + new Field([ + 'fieldDefIdentifier' => 'user_account', + 'languageCode' => 'eng-GB', + 'value' => new ApiUserValue(['login' => 'main-login']), + ]), + new Field([ + 'fieldDefIdentifier' => 'user_account', + 'languageCode' => 'ger-DE', + 'value' => new ApiUserValue(['login' => 'translated-login']), + ]), + ], + 'eng-GB', + 'ger-DE', + 'translated-login', + ]; + } +} diff --git a/tests/lib/Form/Processor/User/UserTranslationSecureListenerTest.php b/tests/lib/Form/Processor/User/UserTranslationSecureListenerTest.php new file mode 100644 index 00000000..8c73be7c --- /dev/null +++ b/tests/lib/Form/Processor/User/UserTranslationSecureListenerTest.php @@ -0,0 +1,89 @@ + 42, 'mainLanguageCode' => $mainLanguageCode]); + $versionInfo = new VersionInfo(['contentInfo' => $contentInfo]); + + return new Content(['versionInfo' => $versionInfo]); + } + + private function createForm(string $languageCode): Form + { + return new Form( + (new FormConfigBuilder('form', null, new EventDispatcher(), [ + 'languageCode' => $languageCode, + ]))->getFormConfig() + ); + } + + private function createEvent(string $formLanguageCode, string $contentMainLanguageCode): FormActionEvent + { + return new FormActionEvent( + $this->createForm($formLanguageCode), + null, + 'publish', + [], + ['content' => $this->createContent($contentMainLanguageCode)] + ); + } + + public function testOnPublishDoesNothingForNonUserContent(): void + { + $event = $this->createEvent('ger-DE', 'eng-GB'); + + $userService = $this->createMock(UserService::class); + $userService->expects(self::once()) + ->method('isUser') + ->with($event->getPayload('content')) + ->willReturn(false); + $userService->expects(self::never())->method('loadUser'); + $userService->expects(self::never())->method('updateUser'); + + $contentService = $this->createMock(ContentService::class); + $contentService->expects(self::never())->method('newContentUpdateStruct'); + + $listener = new UserTranslationSecureListener($userService, $contentService); + + $listener->onPublish($event); + } + + public function testOnPublishDoesNothingWhenPublishingMainLanguage(): void + { + $event = $this->createEvent('eng-GB', 'eng-GB'); + + $userService = $this->createMock(UserService::class); + $userService->method('isUser')->willReturn(true); + $userService->expects(self::never())->method('loadUser'); + $userService->expects(self::never())->method('updateUser'); + + $contentService = $this->createMock(ContentService::class); + $contentService->expects(self::never())->method('newContentUpdateStruct'); + + $listener = new UserTranslationSecureListener($userService, $contentService); + + $listener->onPublish($event); + } +} diff --git a/tests/lib/Form/Processor/User/UserUpdateFormProcessorTest.php b/tests/lib/Form/Processor/User/UserUpdateFormProcessorTest.php index a5c06630..efd51ff7 100644 --- a/tests/lib/Form/Processor/User/UserUpdateFormProcessorTest.php +++ b/tests/lib/Form/Processor/User/UserUpdateFormProcessorTest.php @@ -23,7 +23,7 @@ use ReflectionMethod; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -class UserUpdateFormProcessorTest extends TestCase +final class UserUpdateFormProcessorTest extends TestCase { private function createUser(string $mainLanguageCode): User { @@ -36,12 +36,9 @@ private function createUser(string $mainLanguageCode): User private function createField(string $identifier, bool $isTranslatable, string $value): FieldData { - $field = $this->getMockBuilder(FieldData::class) - ->disableOriginalConstructor() - ->getMock(); + $field = $this->createStub(FieldData::class); $field->value = $value; $field->method('__get') - ->with('fieldDefinition') ->willReturn(new FieldDefinition(['identifier' => $identifier, 'isTranslatable' => $isTranslatable])); return $field; @@ -54,58 +51,70 @@ private function callSetContentFields(UserUpdateFormProcessor $processor, UserUp $method->invoke($processor, $data, $languageCode); } - public function testNonTranslatableFieldIsSkippedOnNonMainLanguageUpdate(): void + private function createUserUpdateData(): UserUpdateData { $data = new UserUpdateData(); $data->user = $this->createUser('eng-GB'); $data->addFieldData($this->createField('title', true, 'translatable-value')); $data->addFieldData($this->createField('user_account', false, 'non-translatable-value')); - $contentUpdateStruct = $this->getMockBuilder(ContentUpdateStruct::class)->getMock(); - $contentUpdateStruct->expects($this->once()) - ->method('setField') - ->with('title', 'translatable-value', 'ger-DE'); + return $data; + } + private function createProcessor(ContentUpdateStruct $contentUpdateStruct): UserUpdateFormProcessor + { $contentService = $this->createMock(ContentService::class); - $contentService->method('newContentUpdateStruct')->willReturn($contentUpdateStruct); + $contentService->expects(self::once()) + ->method('newContentUpdateStruct') + ->willReturn($contentUpdateStruct); - $processor = new UserUpdateFormProcessor( - $this->createMock(UserService::class), + return new UserUpdateFormProcessor( + $this->createStub(UserService::class), $contentService, - $this->createMock(UrlGeneratorInterface::class) + $this->createStub(UrlGeneratorInterface::class) ); - - $this->callSetContentFields($processor, $data, 'ger-DE'); - - self::assertSame('ger-DE', $contentUpdateStruct->initialLanguageCode); } - public function testAllFieldsAreUpdatedOnMainLanguageUpdate(): void + /** + * @dataProvider provideFieldUpdatesForLanguage + * + * @param list $expectedFieldUpdates + */ + public function testSetContentFieldsUpdatesFieldsForLanguage(string $languageCode, array $expectedFieldUpdates): void { - $data = new UserUpdateData(); - $data->user = $this->createUser('eng-GB'); - $data->addFieldData($this->createField('title', true, 'translatable-value')); - $data->addFieldData($this->createField('user_account', false, 'non-translatable-value')); + $data = $this->createUserUpdateData(); $contentUpdateStruct = $this->getMockBuilder(ContentUpdateStruct::class)->getMock(); - $contentUpdateStruct->expects($this->exactly(2)) + $contentUpdateStruct + ->expects(self::exactly(count($expectedFieldUpdates))) ->method('setField') - ->withConsecutive( - ['title', 'translatable-value', 'eng-GB'], - ['user_account', 'non-translatable-value', 'eng-GB'] - ); + ->withConsecutive(...$expectedFieldUpdates); - $contentService = $this->createMock(ContentService::class); - $contentService->method('newContentUpdateStruct')->willReturn($contentUpdateStruct); + $processor = $this->createProcessor($contentUpdateStruct); - $processor = new UserUpdateFormProcessor( - $this->createMock(UserService::class), - $contentService, - $this->createMock(UrlGeneratorInterface::class) - ); + $this->callSetContentFields($processor, $data, $languageCode); - $this->callSetContentFields($processor, $data, 'eng-GB'); + self::assertSame($languageCode, $contentUpdateStruct->initialLanguageCode); + } - self::assertSame('eng-GB', $contentUpdateStruct->initialLanguageCode); + /** + * @return iterable}> + */ + public function provideFieldUpdatesForLanguage(): iterable + { + yield 'non-translatable field is skipped on non-main language update' => [ + 'ger-DE', + [ + ['title', 'translatable-value', 'ger-DE'], + ], + ]; + + yield 'all fields are updated on main language update' => [ + 'eng-GB', + [ + ['title', 'translatable-value', 'eng-GB'], + ['user_account', 'non-translatable-value', 'eng-GB'], + ], + ]; } }