diff --git a/src/Attribute/ITranslatedWithFallbackControl.php b/src/Attribute/ITranslatedWithFallbackControl.php new file mode 100644 index 000000000..8049a72fe --- /dev/null +++ b/src/Attribute/ITranslatedWithFallbackControl.php @@ -0,0 +1,57 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +namespace MetaModels\Attribute; + +/** + * Extended interface for translated attributes that support fetching raw per-language data without automatic fallback + * to the main language. + * + * This separate interface allows opt-in without breaking existing ITranslated implementors. + * Consumers check instanceof before calling getTranslatedDataForWithoutFallback(). + */ +interface ITranslatedWithFallbackControl extends ITranslated +{ + /** + * Get values for the given items in a certain language without falling back to the main language. + * + * Unlike getTranslatedDataFor(), this method returns only data actually stored for $strLangCode. + * No second pass against the main language is performed. + * + * @param list $arrIds The ids for which values shall be retrieved. + * @param string $strLangCode The language code for which the data shall be retrieved. + * + * @return array> the values. + */ + public function getTranslatedDataForWithoutFallback(array $arrIds, string $strLangCode): array; + + /** + * Save translated data for a given language, potentially with attribute-specific transformation. + * + * Implementations may transform the data before persisting it — e.g. a unique-alias attribute + * re-generates the slug to satisfy its uniqueness constraint. The default behaviour is to call + * setTranslatedDataFor() verbatim. + * + * @param array> $arrValues The values keyed by item id. + * @param string $strLangCode The language code being processed. + * + * @return void + */ + public function applyTranslatedDataFor(array $arrValues, string $strLangCode): void; +} diff --git a/src/Attribute/TranslatedReference.php b/src/Attribute/TranslatedReference.php index f7cea603e..b8cf6e05c 100644 --- a/src/Attribute/TranslatedReference.php +++ b/src/Attribute/TranslatedReference.php @@ -3,7 +3,7 @@ /** * This file is part of MetaModels/core. * - * (c) 2012-2024 The MetaModels team. + * (c) 2012-2026 The MetaModels team. * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -19,7 +19,7 @@ * @author Sven Baumann * @author David Molineus * @author Andreas Fischer - * @copyright 2012-2024 The MetaModels team. + * @copyright 2012-2026 The MetaModels team. * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later * @filesource */ @@ -38,7 +38,7 @@ * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ -abstract class TranslatedReference extends BaseComplex implements ITranslated +abstract class TranslatedReference extends BaseComplex implements ITranslatedWithFallbackControl { /** * Database connection. @@ -455,6 +455,24 @@ protected function fetchExistingIdsFor($idList, $langCode) return $queryBuilder->executeQuery()->fetchFirstColumn(); } + /** + * {@inheritDoc} + */ + #[\Override] + public function getTranslatedDataForWithoutFallback(array $arrIds, string $strLangCode): array + { + return $this->getTranslatedDataFor($arrIds, $strLangCode); + } + + /** + * {@inheritDoc} + */ + #[\Override] + public function applyTranslatedDataFor(array $arrValues, string $strLangCode): void + { + $this->setTranslatedDataFor($arrValues, $strLangCode); + } + /** * {@inheritDoc} */ diff --git a/src/CoreBundle/Resources/config/listeners.yml b/src/CoreBundle/Resources/config/listeners.yml index 853888651..2db7e2d24 100644 --- a/src/CoreBundle/Resources/config/listeners.yml +++ b/src/CoreBundle/Resources/config/listeners.yml @@ -48,6 +48,24 @@ services: event: dc-general.model.pre-duplicate method: handle + metamodels.copy_translated_data_listener: + class: MetaModels\DcGeneral\Events\MetaModel\CopyTranslatedData + arguments: + - "@metamodels.factory" + tags: + - name: kernel.event_listener + event: dc-general.model.post-duplicate + method: handle + + metamodels.reset_language_after_duplicate_listener: + class: MetaModels\DcGeneral\Events\MetaModel\ResetLanguageAfterDuplicate + arguments: + - "@metamodels.factory" + tags: + - name: kernel.event_listener + event: dc-general.model.post-duplicate + method: handle + metamodels.sub_system_boot: class: MetaModels\CoreBundle\EventListener\SubSystemBootListener arguments: diff --git a/src/DcGeneral/Events/MetaModel/CopyTranslatedData.php b/src/DcGeneral/Events/MetaModel/CopyTranslatedData.php new file mode 100644 index 000000000..8b380e654 --- /dev/null +++ b/src/DcGeneral/Events/MetaModel/CopyTranslatedData.php @@ -0,0 +1,108 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +namespace MetaModels\DcGeneral\Events\MetaModel; + +use ContaoCommunityAlliance\DcGeneral\Event\PostDuplicateModelEvent; +use MetaModels\Attribute\ITranslatedWithFallbackControl; +use MetaModels\IFactory; +use MetaModels\ITranslatedMetaModel; + +/** + * Copies translated attribute data for all languages when an item is duplicated. + * + * The normal DC_General duplicate path only saves data for the currently active language. This listener runs after + * the copy has been persisted and iterates every language, copying each translated attribute's raw (non-fallback) data + * from the source item to the new item. + */ +final class CopyTranslatedData +{ + /** + * The MetaModels factory. + * + * @var IFactory + */ + private IFactory $factory; + + /** + * Create a new instance. + * + * @param IFactory $factory The factory. + */ + public function __construct(IFactory $factory) + { + $this->factory = $factory; + } + + /** + * Copy translated data for all languages after a model has been duplicated. + * + * @param PostDuplicateModelEvent $event The event. + * + * @return void + */ + public function handle(PostDuplicateModelEvent $event): void + { + $sourceId = (string) $event->getSourceModel()->getId(); + $newId = (string) $event->getModel()->getId(); + + if ('' === $sourceId || '' === $newId || $sourceId === $newId) { + return; + } + + $metaModel = $this->factory->getMetaModel($event->getModel()->getProviderName()); + if (!$metaModel instanceof ITranslatedMetaModel) { + return; + } + + foreach ($metaModel->getLanguages() as $language) { + $this->copyLanguage($metaModel, $language, $sourceId, $newId); + } + } + + /** + * Copy all translated attributes for a single language. + * + * @param ITranslatedMetaModel $metaModel The MetaModel. + * @param string $language The language code. + * @param string $sourceId The source item ID. + * @param string $newId The new item ID. + * + * @return void + */ + private function copyLanguage( + ITranslatedMetaModel $metaModel, + string $language, + string $sourceId, + string $newId + ): void { + foreach ($metaModel->getAttributes() as $attribute) { + if (!$attribute instanceof ITranslatedWithFallbackControl) { + continue; + } + + $data = $attribute->getTranslatedDataForWithoutFallback([$sourceId], $language); + if ([] === $data || !isset($data[$sourceId])) { + continue; + } + + $attribute->applyTranslatedDataFor([$newId => $data[$sourceId]], $language); + } + } +} diff --git a/src/DcGeneral/Events/MetaModel/ResetLanguageAfterDuplicate.php b/src/DcGeneral/Events/MetaModel/ResetLanguageAfterDuplicate.php new file mode 100644 index 000000000..6bd088056 --- /dev/null +++ b/src/DcGeneral/Events/MetaModel/ResetLanguageAfterDuplicate.php @@ -0,0 +1,79 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +namespace MetaModels\DcGeneral\Events\MetaModel; + +use ContaoCommunityAlliance\DcGeneral\Event\PostDuplicateModelEvent; +use ContaoCommunityAlliance\DcGeneral\SessionStorageInterface; +use MetaModels\IFactory; +use MetaModels\ITranslatedMetaModel; + +/** + * Resets the active language to the fallback language after a model is duplicated. + * + * When a new record is created, LanguageFilter automatically resets to the fallback language. Copying a record + * redirects to act=edit with the new ID, so LanguageFilter does not apply the reset. This listener writes the + * fallback language into the dc-general session so that the subsequent edit opens in the fallback language, + * consistent with the behaviour for new records. + */ +final class ResetLanguageAfterDuplicate +{ + /** + * The MetaModels factory. + * + * @var IFactory + */ + private IFactory $factory; + + /** + * Create a new instance. + * + * @param IFactory $factory The factory. + */ + public function __construct(IFactory $factory) + { + $this->factory = $factory; + } + + /** + * Reset the session language to the fallback after a model has been duplicated. + * + * @param PostDuplicateModelEvent $event The event. + * + * @return void + */ + public function handle(PostDuplicateModelEvent $event): void + { + $model = $event->getModel(); + $providerName = $model->getProviderName(); + + $metaModel = $this->factory->getMetaModel($providerName); + if (!$metaModel instanceof ITranslatedMetaModel) { + return; + } + + $environment = $event->getEnvironment(); + $sessionStorage = $environment->getSessionStorage(); + assert($sessionStorage instanceof SessionStorageInterface); + + $session = (array) $sessionStorage->get('dc_general'); + $session['ml_support'][$providerName] = $metaModel->getMainLanguage(); + $sessionStorage->set('dc_general', $session); + } +} diff --git a/tests/DcGeneral/Events/MetaModel/CopyTranslatedDataTest.php b/tests/DcGeneral/Events/MetaModel/CopyTranslatedDataTest.php new file mode 100644 index 000000000..1c41188c4 --- /dev/null +++ b/tests/DcGeneral/Events/MetaModel/CopyTranslatedDataTest.php @@ -0,0 +1,267 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +namespace MetaModels\Test\DcGeneral\Events\MetaModel; + +use ContaoCommunityAlliance\DcGeneral\Data\ModelInterface; +use ContaoCommunityAlliance\DcGeneral\EnvironmentInterface; +use ContaoCommunityAlliance\DcGeneral\Event\PostDuplicateModelEvent; +use MetaModels\Attribute\ITranslated; +use MetaModels\Attribute\ITranslatedWithFallbackControl; +use MetaModels\DcGeneral\Events\MetaModel\CopyTranslatedData; +use MetaModels\IFactory; +use MetaModels\IMetaModel; +use MetaModels\ITranslatedMetaModel; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @covers \MetaModels\DcGeneral\Events\MetaModel\CopyTranslatedData + */ +class CopyTranslatedDataTest extends TestCase +{ + /** + * Build a PostDuplicateModelEvent with the given IDs and provider name. + * + * @param string $sourceId The source item ID. + * @param string $newId The new item ID. + * @param string $providerName The MetaModel table name. + * + * @return PostDuplicateModelEvent + */ + private function buildEvent( + string $sourceId, + string $newId, + string $providerName = 'mm_test' + ): PostDuplicateModelEvent { + $environment = $this->getMockForAbstractClass(EnvironmentInterface::class); + + $sourceModel = $this->getMockForAbstractClass(ModelInterface::class); + $sourceModel->method('getId')->willReturn($sourceId); + + $newModel = $this->getMockForAbstractClass(ModelInterface::class); + $newModel->method('getId')->willReturn($newId); + $newModel->method('getProviderName')->willReturn($providerName); + + return new PostDuplicateModelEvent($environment, $newModel, $sourceModel); + } + + /** + * Build a CopyTranslatedData listener with the given MetaModel (or null for "not found"). + * + * @param IMetaModel|null $metaModel The MetaModel to return from factory, or null. + * @param string $providerName The provider name used for factory lookup. + * + * @return CopyTranslatedData + */ + private function buildListener(?IMetaModel $metaModel, string $providerName = 'mm_test'): CopyTranslatedData + { + /** @var IFactory&MockObject $factory */ + $factory = $this->getMockForAbstractClass(IFactory::class); + $factory->method('getMetaModel')->with($providerName)->willReturn($metaModel); + + return new CopyTranslatedData($factory); + } + + /** + * Nothing happens when source and new ID are identical. + */ + public function testHandleDoesNothingWhenIdsAreEqual(): void + { + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->expects(self::never())->method('getLanguages'); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent('42', '42')); + } + + /** + * Nothing happens when source ID is empty. + */ + public function testHandleDoesNothingWhenSourceIdIsEmpty(): void + { + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->expects(self::never())->method('getLanguages'); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent('', '99')); + } + + /** + * Nothing happens when new ID is empty. + */ + public function testHandleDoesNothingWhenNewIdIsEmpty(): void + { + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->expects(self::never())->method('getLanguages'); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent('42', '')); + } + + /** + * Nothing happens when the MetaModel does not implement ITranslatedMetaModel. + */ + public function testHandleDoesNothingForNonTranslatedMetaModel(): void + { + /** @var IMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(IMetaModel::class); + $metaModel->expects(self::never())->method('getAttributes'); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent('1', '2')); + } + + /** + * Attributes that only implement ITranslated (not ITranslatedWithFallbackControl) are skipped. + */ + public function testHandleSkipsAttributesWithoutFallbackControlInterface(): void + { + /** @var ITranslated&MockObject $attribute */ + $attribute = $this->getMockForAbstractClass(ITranslated::class); + $attribute->expects(self::never())->method('setTranslatedDataFor'); + + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->method('getLanguages')->willReturn(['de', 'en']); + $metaModel->method('getAttributes')->willReturn([$attribute]); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent('1', '2')); + } + + /** + * Languages for which the attribute holds no data are skipped (applyTranslatedDataFor not called). + */ + public function testHandleSkipsLanguagesWithNoData(): void + { + /** @var ITranslatedWithFallbackControl&MockObject $attribute */ + $attribute = $this->getMockForAbstractClass(ITranslatedWithFallbackControl::class); + $attribute->method('getTranslatedDataForWithoutFallback')->willReturn([]); + $attribute->expects(self::never())->method('applyTranslatedDataFor'); + + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->method('getLanguages')->willReturn(['de', 'en']); + $metaModel->method('getAttributes')->willReturn([$attribute]); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent('1', '2')); + } + + /** + * Happy path: for each language and attribute with data, applyTranslatedDataFor is called with the new ID. + */ + public function testHandleCopiesDataForAllLanguages(): void + { + $sourceId = '10'; + $newId = '99'; + + $dataDE = ['item_id' => $sourceId, 'langcode' => 'de', 'value' => 'Hallo']; + $dataEN = ['item_id' => $sourceId, 'langcode' => 'en', 'value' => 'Hello']; + + /** @var ITranslatedWithFallbackControl&MockObject $attribute */ + $attribute = $this->getMockForAbstractClass(ITranslatedWithFallbackControl::class); + $attribute->method('getTranslatedDataForWithoutFallback')->willReturnMap([ + [[$sourceId], 'de', [$sourceId => $dataDE]], + [[$sourceId], 'en', [$sourceId => $dataEN]], + ]); + + $attribute->expects(self::exactly(2))->method('applyTranslatedDataFor')->willReturnCallback( + static function (array $arrValues, string $lang) use ($newId, $dataDE, $dataEN): void { + self::assertArrayHasKey($newId, $arrValues, "New ID must be the array key for lang={$lang}"); + if ('de' === $lang) { + self::assertSame($dataDE, $arrValues[$newId]); + } else { + self::assertSame($dataEN, $arrValues[$newId]); + } + } + ); + + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->method('getLanguages')->willReturn(['de', 'en']); + $metaModel->method('getAttributes')->willReturn([$attribute]); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent($sourceId, $newId)); + } + + /** + * Only languages that actually have data are saved; others are silently skipped. + */ + public function testHandleCopiesOnlyLanguagesWithActualData(): void + { + $sourceId = '5'; + $newId = '7'; + + $dataDE = ['item_id' => $sourceId, 'langcode' => 'de', 'value' => 'Hallo']; + + /** @var ITranslatedWithFallbackControl&MockObject $attribute */ + $attribute = $this->getMockForAbstractClass(ITranslatedWithFallbackControl::class); + $attribute->method('getTranslatedDataForWithoutFallback')->willReturnMap([ + [[$sourceId], 'de', [$sourceId => $dataDE]], + [[$sourceId], 'en', []], + ]); + + $attribute->expects(self::once())->method('applyTranslatedDataFor') + ->with([$newId => $dataDE], 'de'); + + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->method('getLanguages')->willReturn(['de', 'en']); + $metaModel->method('getAttributes')->willReturn([$attribute]); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent($sourceId, $newId)); + } + + /** + * Multiple attributes per language are each handled independently. + */ + public function testHandleProcessesMultipleAttributes(): void + { + $sourceId = '3'; + $newId = '8'; + + $data1 = ['item_id' => $sourceId, 'langcode' => 'de', 'value' => 'Attr1']; + $data2 = ['item_id' => $sourceId, 'langcode' => 'de', 'value' => 'Attr2']; + + /** @var ITranslatedWithFallbackControl&MockObject $attribute1 */ + $attribute1 = $this->getMockForAbstractClass(ITranslatedWithFallbackControl::class); + $attribute1->method('getTranslatedDataForWithoutFallback')->willReturn([$sourceId => $data1]); + $attribute1->expects(self::once())->method('applyTranslatedDataFor')->with([$newId => $data1], 'de'); + + /** @var ITranslatedWithFallbackControl&MockObject $attribute2 */ + $attribute2 = $this->getMockForAbstractClass(ITranslatedWithFallbackControl::class); + $attribute2->method('getTranslatedDataForWithoutFallback')->willReturn([$sourceId => $data2]); + $attribute2->expects(self::once())->method('applyTranslatedDataFor')->with([$newId => $data2], 'de'); + + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->method('getLanguages')->willReturn(['de']); + $metaModel->method('getAttributes')->willReturn([$attribute1, $attribute2]); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent($sourceId, $newId)); + } +} diff --git a/tests/DcGeneral/Events/MetaModel/ResetLanguageAfterDuplicateTest.php b/tests/DcGeneral/Events/MetaModel/ResetLanguageAfterDuplicateTest.php new file mode 100644 index 000000000..0fe76e54e --- /dev/null +++ b/tests/DcGeneral/Events/MetaModel/ResetLanguageAfterDuplicateTest.php @@ -0,0 +1,174 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +namespace MetaModels\Test\DcGeneral\Events\MetaModel; + +use ContaoCommunityAlliance\DcGeneral\Data\ModelInterface; +use ContaoCommunityAlliance\DcGeneral\EnvironmentInterface; +use ContaoCommunityAlliance\DcGeneral\Event\PostDuplicateModelEvent; +use ContaoCommunityAlliance\DcGeneral\SessionStorageInterface; +use MetaModels\DcGeneral\Events\MetaModel\ResetLanguageAfterDuplicate; +use MetaModels\IFactory; +use MetaModels\IMetaModel; +use MetaModels\ITranslatedMetaModel; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @covers \MetaModels\DcGeneral\Events\MetaModel\ResetLanguageAfterDuplicate + */ +class ResetLanguageAfterDuplicateTest extends TestCase +{ + /** + * Build a PostDuplicateModelEvent for the given provider name. + * + * @param string $providerName The MetaModel table name. + * @param SessionStorageInterface $sessionStorage The session storage mock. + * + * @return PostDuplicateModelEvent + */ + private function buildEvent(string $providerName, SessionStorageInterface $sessionStorage): PostDuplicateModelEvent + { + /** @var SessionStorageInterface&MockObject $sessionStorage */ + $environment = $this->getMockForAbstractClass(EnvironmentInterface::class); + $environment->method('getSessionStorage')->willReturn($sessionStorage); + + $sourceModel = $this->getMockForAbstractClass(ModelInterface::class); + $sourceModel->method('getId')->willReturn('1'); + + $newModel = $this->getMockForAbstractClass(ModelInterface::class); + $newModel->method('getId')->willReturn('2'); + $newModel->method('getProviderName')->willReturn($providerName); + + return new PostDuplicateModelEvent($environment, $newModel, $sourceModel); + } + + /** + * Build a ResetLanguageAfterDuplicate listener that returns the given MetaModel from the factory. + * + * @param IMetaModel|null $metaModel The MetaModel to return, or null. + * @param string $providerName The provider name used for factory lookup. + * + * @return ResetLanguageAfterDuplicate + */ + private function buildListener( + ?IMetaModel $metaModel, + string $providerName = 'mm_test' + ): ResetLanguageAfterDuplicate { + /** @var IFactory&MockObject $factory */ + $factory = $this->getMockForAbstractClass(IFactory::class); + $factory->method('getMetaModel')->with($providerName)->willReturn($metaModel); + + return new ResetLanguageAfterDuplicate($factory); + } + + /** + * Nothing happens when the MetaModel is not a translated MetaModel. + */ + public function testHandleDoesNothingForNonTranslatedMetaModel(): void + { + /** @var SessionStorageInterface&MockObject $sessionStorage */ + $sessionStorage = $this->getMockForAbstractClass(SessionStorageInterface::class); + $sessionStorage->expects(self::never())->method('set'); + + /** @var IMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(IMetaModel::class); + + $listener = $this->buildListener($metaModel); + $listener->handle($this->buildEvent('mm_test', $sessionStorage)); + } + + /** + * Nothing happens when the MetaModel cannot be found. + */ + public function testHandleDoesNothingWhenMetaModelNotFound(): void + { + /** @var SessionStorageInterface&MockObject $sessionStorage */ + $sessionStorage = $this->getMockForAbstractClass(SessionStorageInterface::class); + $sessionStorage->expects(self::never())->method('set'); + + $listener = $this->buildListener(null); + $listener->handle($this->buildEvent('mm_test', $sessionStorage)); + } + + /** + * The fallback language is written into the session for the provider name. + */ + public function testHandleWritesFallbackLanguageToSession(): void + { + $providerName = 'mm_test'; + $fallbackLanguage = 'de'; + + /** @var SessionStorageInterface&MockObject $sessionStorage */ + $sessionStorage = $this->getMockForAbstractClass(SessionStorageInterface::class); + $sessionStorage->method('get')->with('dc_general')->willReturn([]); + $sessionStorage + ->expects(self::once()) + ->method('set') + ->with( + 'dc_general', + self::callback(static function (array $session) use ($providerName, $fallbackLanguage): bool { + return ($session['ml_support'][$providerName] ?? null) === $fallbackLanguage; + }) + ); + + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->method('getMainLanguage')->willReturn($fallbackLanguage); + + $listener = $this->buildListener($metaModel, $providerName); + $listener->handle($this->buildEvent($providerName, $sessionStorage)); + } + + /** + * Existing session data is preserved; only the ml_support entry for the provider is overwritten. + */ + public function testHandlePreservesExistingSessionData(): void + { + $providerName = 'mm_news'; + $fallbackLanguage = 'en'; + + /** @var SessionStorageInterface&MockObject $sessionStorage */ + $sessionStorage = $this->getMockForAbstractClass(SessionStorageInterface::class); + $sessionStorage->method('get')->with('dc_general')->willReturn([ + 'ml_support' => ['mm_other' => 'fr'], + 'some_key' => 'some_value', + ]); + $sessionStorage + ->expects(self::once()) + ->method('set') + ->with( + 'dc_general', + self::callback( + static function (array $session) use ($providerName, $fallbackLanguage): bool { + return ($session['ml_support'][$providerName] ?? null) === $fallbackLanguage + && ($session['ml_support']['mm_other'] ?? null) === 'fr' + && ($session['some_key'] ?? null) === 'some_value'; + } + ) + ); + + /** @var ITranslatedMetaModel&MockObject $metaModel */ + $metaModel = $this->getMockForAbstractClass(ITranslatedMetaModel::class); + $metaModel->method('getMainLanguage')->willReturn($fallbackLanguage); + + $listener = $this->buildListener($metaModel, $providerName); + $listener->handle($this->buildEvent($providerName, $sessionStorage)); + } +}