From c84274984e98ab167cee1b007efa8180b23f4882 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Mon, 10 Aug 2026 09:48:20 +0200 Subject: [PATCH 1/5] feat: updated composer file and added phpstan and coding standards --- .gitattributes | 5 ++ .../workflows/apply-coding-standard.app.yml | 54 +++++++++++++++++++ composer.json | 30 +++++++++-- ecs.php | 16 ++++++ phpstan.neon.dist | 7 +++ phpstan.shopware-6.7.1.0.neon.dist | 7 +++ 6 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/apply-coding-standard.app.yml create mode 100644 ecs.php create mode 100644 phpstan.neon.dist create mode 100644 phpstan.shopware-6.7.1.0.neon.dist diff --git a/.gitattributes b/.gitattributes index 42bf9be..17d55c9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,4 +11,9 @@ /phpstan.neon.dist export-ignore /phpstan.shopware-6.7.1.0.neon.dist export-ignore /phpunit.xml.dist export-ignore +/phpunit.xml export-ignore +/phpunit.cache export-ignore /rector.php export-ignore +/composer.lock export-ignore +/AGENTS.md export-ignore +/magi.toml export-ignore diff --git a/.github/workflows/apply-coding-standard.app.yml b/.github/workflows/apply-coding-standard.app.yml new file mode 100644 index 0000000..6ba674a --- /dev/null +++ b/.github/workflows/apply-coding-standard.app.yml @@ -0,0 +1,54 @@ +name: Apply Coding Standard + +on: + push: + branches: + - main + paths: + - 'src/**' + +permissions: + contents: write + pull-requests: write + +jobs: + php-coding-standard: + name: 'Apply PHP Coding Standard' + runs-on: ubuntu-latest + steps: + - name: 'Checkout Code' + uses: actions/checkout@v4 + + - name: 'Setup PHP' + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer + + - name: 'Validate composer.json and composer.lock' + shell: bash + run: composer validate + + - name: 'Install Composer Dependencies' + shell: bash + run: composer install --prefer-dist --no-progress --no-interaction --optimize-autoloader + + - name: 'ECS PHP Code' + shell: bash + run: composer lint:fix + + - name: 'Create pull-request' + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "[automated] Apply Coding Standard" + branch: 'automated-apply-coding-standards' + title: '[automated] Apply Coding Standard' + labels: 'automated' + delete-branch: true + token: ${{ secrets.GITHUB_TOKEN }} + + - name: 'Enable Pull Request Merge when ready' + if: steps.cpr.outputs.pull-request-operation == 'created' + run: gh pr merge --auto "${{ steps.cpr.outputs.pull-request-number }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/composer.json b/composer.json index b285c65..8616c4d 100644 --- a/composer.json +++ b/composer.json @@ -3,16 +3,24 @@ "description": "Load storefront translations from symfony/translation provider", "license": "MIT", "type": "shopware-platform-plugin", + "authors": [ + { + "name": "netlogix GmbH & Co. KG" + } + ], "require": { "php": ">8.3", "shopware/storefront": ">=6.7.1.0", + "shopware/core": "~6.7.1", "symfony/translation": ">7.0.0" }, "require-dev": { "carthage-software/mago": "*", "ergebnis/composer-normalize": "*", "frosh/shopware-rector": "*", - "phpunit/phpunit": "^13.0.0" + "phpunit/phpunit": "^13.0.0", + "netlogix/coding-guidelines-php": "^1.0", + "shopwarelabs/phpstan-shopware": "^0.2.3" }, "autoload": { "psr-4": { @@ -28,15 +36,28 @@ "allow-plugins": { "carthage-software/mago": true, "ergebnis/composer-normalize": true, - "symfony/runtime": false + "symfony/runtime": false, + "php-http/discovery": true } }, "extra": { + "description": { + "de-DE": "Dieses Plugin fügt Shopware Support für externe Translation Provider hinzu. Damit lassen sich Übersetzungen aus Symfony Translation Providern, wie z.B. Tolgee, in Shopware laden.", + "en-GB": "This plugin adds support for external translation providers to Shopware. It allows loading translations from Symfony translation providers, such as Tolgee, into Shopware." + }, "label": { "de-DE": "Translation Bridge", "en-GB": "Translation Bridge" }, - "shopware-plugin-class": "Netlogix\\ShopwareTranslationBridge\\ShopwareTranslationBridge" + "manufacturerLink": { + "de-DE": "https://www.netlogix.de", + "en-GB": "https://www.netlogix.de" + }, + "shopware-plugin-class": "Netlogix\\ShopwareTranslationBridge\\ShopwareTranslationBridge", + "supportLink": { + "de-DE": "https://www.netlogix.de", + "en-GB": "https://www.netlogix.de" + } }, "scripts": { "apply-coding-standard": [ @@ -49,6 +70,7 @@ "format": "mago fmt --dry-run", "format:fix": "mago fmt", "lint": "mago lint", + "phpstan": "vendor/bin/phpstan", "rector": "rector process --dry-run", "rector:fix": "rector process", "test": [ @@ -58,4 +80,4 @@ "test:unit": "phpunit --testdox --testsuite unit" }, "version": "1.0.0" -} \ No newline at end of file +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..fe7e6d4 --- /dev/null +++ b/ecs.php @@ -0,0 +1,16 @@ +configure($ecsConfig); + + $ecsConfig->paths( + [ + __DIR__ . '/src', + ] + ); +}; diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..3cdd64c --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +includes: + - vendor/shopwarelabs/phpstan-shopware/rules.neon + +parameters: + level: 5 + paths: + - src/ \ No newline at end of file diff --git a/phpstan.shopware-6.7.1.0.neon.dist b/phpstan.shopware-6.7.1.0.neon.dist new file mode 100644 index 0000000..7316016 --- /dev/null +++ b/phpstan.shopware-6.7.1.0.neon.dist @@ -0,0 +1,7 @@ +includes: + - phpstan.neon.dist + +parameters: + ignoreErrors: + - message: '#Access to an undefined property Shopware\\Core\\Content\\Media\\Extension\\ResolveRemoteThumbnailUrlExtension::\$mediaEntity\.#' + path: src/EventListener/RemoteThumbnailUrlResolver.php From 866b063c5dd49ba508e25a308986267861c2003a Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Mon, 10 Aug 2026 09:56:35 +0200 Subject: [PATCH 2/5] feat: updated unit tests --- .../Unit/Command/PullSnippetsCommandTest.php | 157 ++++++++++-------- .../Unit/Command/PushSnippetsCommandTest.php | 8 +- .../UpdateTranslationControllerTest.php | 6 +- .../Listener/LoadTranslationsListenerTest.php | 15 +- .../TranslationProviderResolverTest.php | 107 +++++++----- 5 files changed, 170 insertions(+), 123 deletions(-) diff --git a/tests/Unit/Command/PullSnippetsCommandTest.php b/tests/Unit/Command/PullSnippetsCommandTest.php index a4302a0..e4ff2b9 100644 --- a/tests/Unit/Command/PullSnippetsCommandTest.php +++ b/tests/Unit/Command/PullSnippetsCommandTest.php @@ -4,79 +4,92 @@ namespace Netlogix\ShopwareTranslationBridge\Tests\Unit\Command; -use Symfony\Component\Translation\MessageCatalogue; use Netlogix\ShopwareTranslationBridge\Command\PullSnippetsCommand; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface; +use Netlogix\ShopwareTranslationBridge\Resolver\ConfigurationResolver; use Netlogix\ShopwareTranslationBridge\Tests\Support\HelperService; -use Netlogix\ShopwareTranslationBridge\Tests\Support\Provider\InMemoryTestProviderFactory; +use Netlogix\ShopwareTranslationBridge\Tests\Support\Provider\InMemoryTestProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; +use Shopware\Core\Framework\DataAbstractionLayer\Search\IdSearchResult; use Shopware\Core\System\SalesChannel\SalesChannelCollection; use Shopware\Core\System\SalesChannel\SalesChannelEntity; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\TranslatorBag; -use Symfony\Component\Translation\Provider\TranslationProviderCollection; use Symfony\Component\Translation\Writer\TranslationWriterInterface; #[CoversClass(PullSnippetsCommand::class)] final class PullSnippetsCommandTest extends TestCase { private const string SALES_CHANNEL_ID = '2b919afec10730f413cb5682bbed09fd'; + private const string TRANSLATOR_DEFAULT_PATH = '/tmp/nlx-translation-bridge'; private HelperService $helperService; - private InMemoryTestProviderFactory $providerFactory; protected function setUp(): void { $this->helperService = new HelperService(); - $this->providerFactory = new InMemoryTestProviderFactory(); } - public function testExecuteWarnsWhenNoProviderConfigurationExists(): void + public function testExecuteWarnsWhenNoProviderIsConfigured(): void { - $languageRepository = $this->createStub(EntityRepository::class); - $salesChannelRepository = $this->createStub(EntityRepository::class); + $resolver = $this->createStub(TranslationProviderResolverInterface::class); + $resolver->method('hasProvider')->willReturn(false); + $writer = $this->createRecordingWriter(); $command = new PullSnippetsCommand( - new TranslationProviderCollection([]), - $languageRepository, - $salesChannelRepository, + $resolver, + $this->createStub(ConfigurationResolver::class), + $this->createStub(EntityRepository::class), + $this->createStub(EntityRepository::class), $writer, - $this->createProjectDir(), - null, - [] + self::TRANSLATOR_DEFAULT_PATH ); $commandTester = new CommandTester($command); $status = $commandTester->execute([]); static::assertSame(Command::SUCCESS, $status); - static::assertStringContainsString('No translations fetched', $commandTester->getDisplay()); + static::assertStringContainsString('No translation provider configured', $commandTester->getDisplay()); static::assertCount(0, $writer->writes); } public function testExecuteFetchesFromDefaultProviderAndWritesTranslations(): void { - $providers = $this->providerFactory->createPullProviderCollection(); - $writer = $this->createRecordingWriter(); + $provider = new InMemoryTestProvider( + 'default-provider', + $this->helperService->createBag('de-DE', ['welcome' => 'Willkommen']) + ); + + $resolver = $this->createStub(TranslationProviderResolverInterface::class); + $resolver->method('hasProvider')->willReturn(true); + $resolver->method('getProvider')->willReturn($provider); + + $configurationResolver = $this->createStub(ConfigurationResolver::class); + $configurationResolver->method('getProviderName')->willReturn('default-provider'); $languageRepository = $this->createStub(EntityRepository::class); $languageRepository->method('search')->willReturn($this->createLanguageSearchResult(['de-DE'])); + $salesChannelRepository = $this->createStub(EntityRepository::class); + $salesChannelRepository->method('searchIds')->willReturn($this->createIdSearchResult([])); + + $writer = $this->createRecordingWriter(); $command = new PullSnippetsCommand( - $providers, + $resolver, + $configurationResolver, $languageRepository, $salesChannelRepository, $writer, - $this->createProjectDir(), - 'default-provider', - [] + self::TRANSLATOR_DEFAULT_PATH ); $commandTester = new CommandTester($command); @@ -87,33 +100,42 @@ public function testExecuteFetchesFromDefaultProviderAndWritesTranslations(): vo static::assertCount(1, $writer->writes); static::assertSame('json', $writer->writes[0]['format']); static::assertArrayHasKey('path', $writer->writes[0]['options']); + $writtenBag = new TranslatorBag(); $writtenBag->addCatalogue($writer->writes[0]['catalogue']); static::assertSame( - $this->helperService->translatorBagToArray($this->helperService->createBag('de-DE', [ - 'welcome' => 'Willkommen' - ])), + $this->helperService->translatorBagToArray( + $this->helperService->createBag('de-DE', ['welcome' => 'Willkommen']) + ), $this->helperService->translatorBagToArray($writtenBag) ); } - public function testExecuteSkipsSalesChannelProviderWhenNoLocalesWereFound(): void + public function testExecuteWarnsWhenNoTranslationsWereFetched(): void { - $providers = $this->providerFactory->createPullProviderCollection(); + $resolver = $this->createStub(TranslationProviderResolverInterface::class); + $resolver->method('hasProvider')->willReturn(true); + $resolver->method('getProvider')->willReturn(new InMemoryTestProvider('default-provider')); + + $configurationResolver = $this->createStub(ConfigurationResolver::class); + $configurationResolver->method('getProviderName')->willReturn('default-provider'); + // No enabled locales -> nothing can be fetched for the default provider. $languageRepository = $this->createStub(EntityRepository::class); + $languageRepository->method('search')->willReturn($this->createLanguageSearchResult([])); + $salesChannelRepository = $this->createStub(EntityRepository::class); - $salesChannelRepository->method('search')->willReturn($this->createSalesChannelSearchResult()); + $salesChannelRepository->method('searchIds')->willReturn($this->createIdSearchResult([])); + $writer = $this->createRecordingWriter(); $command = new PullSnippetsCommand( - $providers, + $resolver, + $configurationResolver, $languageRepository, $salesChannelRepository, $writer, - $this->createProjectDir(), - null, - [self::SALES_CHANNEL_ID => 'sales-provider'] + self::TRANSLATOR_DEFAULT_PATH ); $commandTester = new CommandTester($command); @@ -124,53 +146,53 @@ public function testExecuteSkipsSalesChannelProviderWhenNoLocalesWereFound(): vo static::assertCount(0, $writer->writes); } - public function testExecuteThrowsExceptionWhenProviderNotFound(): void + public function testExecuteFetchesFromSalesChannelProviderOverride(): void { - $languageRepository = $this->createStub(EntityRepository::class); - $languageRepository->method('search')->willReturn($this->createLanguageSearchResult(['de-DE'])); - $salesChannelRepository = $this->createStub(EntityRepository::class); - - $command = new PullSnippetsCommand( - new TranslationProviderCollection([]), - $languageRepository, - $salesChannelRepository, - $this->createRecordingWriter(), - $this->createProjectDir(), - 'non-existent-provider', - [] + $defaultProvider = new InMemoryTestProvider('default-provider'); + $salesChannelProvider = new InMemoryTestProvider( + 'sales-provider', + $this->helperService->createBag('de-DE', ['checkout' => 'Kasse']) ); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Provider "non-existent-provider" not found.'); - - $commandTester = new CommandTester($command); - $commandTester->execute([]); - } + $resolver = $this->createStub(TranslationProviderResolverInterface::class); + $resolver->method('hasProvider')->willReturn(true); + $resolver->method('getProvider')->willReturnCallback( + static fn (?string $salesChannelId = null): InMemoryTestProvider => + $salesChannelId === null ? $defaultProvider : $salesChannelProvider + ); - public function testExecuteFetchesFromSalesChannelProviderAndWritesTranslations(): void - { - $providers = $this->providerFactory->createPullProviderCollection(); - $writer = $this->createRecordingWriter(); + // Default provider name differs from the sales-channel one -> channel is treated as an override. + $configurationResolver = $this->createStub(ConfigurationResolver::class); + $configurationResolver->method('getProviderName')->willReturnCallback( + static fn (?string $salesChannelId = null): string => + $salesChannelId === null ? 'default-provider' : 'sales-provider' + ); + // No global locales -> only the sales-channel override produces a write. $languageRepository = $this->createStub(EntityRepository::class); + $languageRepository->method('search')->willReturn($this->createLanguageSearchResult([])); $salesChannel = new SalesChannelEntity(); $salesChannel->setUniqueIdentifier(self::SALES_CHANNEL_ID); $salesChannel->setLanguages($this->helperService->createLanguageCollection(['de-DE'])); $salesChannelRepository = $this->createStub(EntityRepository::class); + $salesChannelRepository->method('searchIds')->willReturn( + $this->createIdSearchResult([self::SALES_CHANNEL_ID]) + ); $salesChannelRepository->method('search')->willReturn( $this->createSalesChannelSearchResult($salesChannel) ); + $writer = $this->createRecordingWriter(); + $command = new PullSnippetsCommand( - $providers, + $resolver, + $configurationResolver, $languageRepository, $salesChannelRepository, $writer, - $this->createProjectDir(), - null, - [self::SALES_CHANNEL_ID => 'sales-provider'] + self::TRANSLATOR_DEFAULT_PATH ); $commandTester = new CommandTester($command); @@ -185,14 +207,6 @@ public function testExecuteFetchesFromSalesChannelProviderAndWritesTranslations( static::assertSame('Kasse', $catalogue->get('checkout', self::SALES_CHANNEL_ID)); } - private function createProjectDir(): string - { - $projectDir = sys_get_temp_dir() . '/shopware-translation-bridge-tests-' . uniqid('', true); - mkdir($projectDir, recursive: true); - - return $projectDir; - } - /** * @param list $localeCodes */ @@ -222,6 +236,17 @@ private function createSalesChannelSearchResult(?SalesChannelEntity $salesChanne ); } + /** + * @param list $ids + */ + private function createIdSearchResult(array $ids): IdSearchResult + { + $idSearchResult = $this->createStub(IdSearchResult::class); + $idSearchResult->method('getIds')->willReturn($ids); + + return $idSearchResult; + } + private function createRecordingWriter(): object { return new class() implements TranslationWriterInterface { diff --git a/tests/Unit/Command/PushSnippetsCommandTest.php b/tests/Unit/Command/PushSnippetsCommandTest.php index 6eab82f..2529aad 100644 --- a/tests/Unit/Command/PushSnippetsCommandTest.php +++ b/tests/Unit/Command/PushSnippetsCommandTest.php @@ -56,7 +56,7 @@ public function testExecutePushesAllTranslationsToDefaultProviderWithForce(): vo static::assertInstanceOf(InMemoryTestProvider::class, $provider); $providerResolver = $this->createStub(TranslationProviderResolverInterface::class); - $providerResolver->method('getDefaultProvider')->willReturn($provider); + $providerResolver->method('getProvider')->willReturn($provider); $translator = $this->createStub(AbstractTranslator::class); $translator->method('getSnippetSetId')->willReturn('snippet-set-id'); @@ -92,7 +92,7 @@ public function testExecuteDeletesMissingAndWritesDiffForSalesChannelProvider(): static::assertInstanceOf(InMemoryTestProvider::class, $provider); $providerResolver = $this->createStub(TranslationProviderResolverInterface::class); - $providerResolver->method('getSalesChannelProvider')->willReturn($provider); + $providerResolver->method('getProvider')->willReturn($provider); $translator = $this->createStub(AbstractTranslator::class); $translator->method('getSnippetSetId')->willReturn('snippet-set-id'); @@ -138,7 +138,7 @@ public function testExecutePushesOnlyNewTranslationsWithoutForce(): void ])); $providerResolver = $this->createStub(TranslationProviderResolverInterface::class); - $providerResolver->method('getDefaultProvider')->willReturn($provider); + $providerResolver->method('getProvider')->willReturn($provider); $translator = $this->createStub(AbstractTranslator::class); $translator->method('getSnippetSetId')->willReturn('snippet-set-id'); @@ -188,7 +188,7 @@ public function testExecuteOverridesExistingTranslationsWithForce(): void ])); $providerResolver = $this->createStub(TranslationProviderResolverInterface::class); - $providerResolver->method('getDefaultProvider')->willReturn($provider); + $providerResolver->method('getProvider')->willReturn($provider); $translator = $this->createStub(AbstractTranslator::class); $translator->method('getSnippetSetId')->willReturn('snippet-set-id'); diff --git a/tests/Unit/Core/Framework/Api/Controller/UpdateTranslationControllerTest.php b/tests/Unit/Core/Framework/Api/Controller/UpdateTranslationControllerTest.php index 28c46a6..012a912 100644 --- a/tests/Unit/Core/Framework/Api/Controller/UpdateTranslationControllerTest.php +++ b/tests/Unit/Core/Framework/Api/Controller/UpdateTranslationControllerTest.php @@ -32,10 +32,9 @@ public function testInvokeReturnsServiceUnavailableWhenNoProviderCanBeResolved() ->method('searchIds') ->willReturn($this->createIdSearchResult([self::SALES_CHANNEL_ID_1])); - $providerResolver->expects(self::once())->method('hasDefaultProvider')->willReturn(false); $providerResolver ->expects(self::once()) - ->method('hasSalesChannelProvider') + ->method('hasProvider') ->with(self::SALES_CHANNEL_ID_1) ->willReturn(false); @@ -63,8 +62,7 @@ public function testInvokeDispatchesBatchedMessages(): void self::SALES_CHANNEL_ID_3 ])); - $providerResolver->expects(self::once())->method('hasDefaultProvider')->willReturn(true); - $providerResolver->expects(self::never())->method('hasSalesChannelProvider'); + $providerResolver->expects(self::exactly(3))->method('hasProvider')->willReturn(true); $messageBus ->expects(self::exactly(2)) diff --git a/tests/Unit/Core/System/Snippet/Listener/LoadTranslationsListenerTest.php b/tests/Unit/Core/System/Snippet/Listener/LoadTranslationsListenerTest.php index 6f05896..9ab51af 100644 --- a/tests/Unit/Core/System/Snippet/Listener/LoadTranslationsListenerTest.php +++ b/tests/Unit/Core/System/Snippet/Listener/LoadTranslationsListenerTest.php @@ -6,6 +6,7 @@ use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Listener\LoadTranslationsListener; use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface; +use Netlogix\ShopwareTranslationBridge\Resolver\ConfigurationResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Shopware\Core\System\Snippet\Extension\StorefrontSnippetsExtension; @@ -42,7 +43,7 @@ public function testInvokeRespectsTranslationFilesWhenNoProviderExists(): void [] ); - $listener = new LoadTranslationsListener($providerResolver, true); + $listener = new LoadTranslationsListener($providerResolver, $this->createConfigurationResolver(true)); $listener($extension); static::assertFalse($extension->isPropagationStopped()); @@ -100,7 +101,7 @@ public function testInvokeAppliesProviderTranslationsAndStopsPropagation(): void ); $extension->result = $extension->snippets; - $listener = new LoadTranslationsListener($providerResolver, false); + $listener = new LoadTranslationsListener($providerResolver, $this->createConfigurationResolver(false)); $listener($extension); static::assertTrue($extension->isPropagationStopped()); @@ -114,7 +115,7 @@ public function testSkipSuppressesInvocationOnlyInsideCallback(): void $providerResolver = $this->createMock(TranslationProviderResolverInterface::class); $providerResolver->expects(static::once())->method('hasProvider')->willReturn(false); - $listener = new LoadTranslationsListener($providerResolver, false); + $listener = new LoadTranslationsListener($providerResolver, $this->createConfigurationResolver(false)); $extension = new StorefrontSnippetsExtension( ['headline' => 'snippet-value'], 'de-DE', @@ -129,4 +130,12 @@ public function testSkipSuppressesInvocationOnlyInsideCallback(): void LoadTranslationsListener::skip(static fn() => $listener($extension)); $listener($extension); } + + private function createConfigurationResolver(bool $respectTranslationFiles): ConfigurationResolver + { + $configurationResolver = $this->createStub(ConfigurationResolver::class); + $configurationResolver->method('respectTranslationFiles')->willReturn($respectTranslationFiles); + + return $configurationResolver; + } } diff --git a/tests/Unit/Core/System/Snippet/TranslationProviderResolverTest.php b/tests/Unit/Core/System/Snippet/TranslationProviderResolverTest.php index 99fbea3..9502a7f 100644 --- a/tests/Unit/Core/System/Snippet/TranslationProviderResolverTest.php +++ b/tests/Unit/Core/System/Snippet/TranslationProviderResolverTest.php @@ -4,13 +4,14 @@ namespace Netlogix\ShopwareTranslationBridge\Tests\Unit\Core\System\Snippet; -use InvalidArgumentException; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Exception\MissingDefaultProviderException; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Exception\MissingSalesChannelProviderException; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Exception\UnknownProviderException; use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolver; +use Netlogix\ShopwareTranslationBridge\Resolver\ConfigurationResolver; use Netlogix\ShopwareTranslationBridge\Tests\Support\Provider\InMemoryTestProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use ReflectionProperty; -use RuntimeException; use Symfony\Component\Translation\Provider\TranslationProviderCollection; #[CoversClass(TranslationProviderResolver::class)] @@ -18,85 +19,99 @@ final class TranslationProviderResolverTest extends TestCase { private const string SALES_CHANNEL_ID = '2b919afec10730f413cb5682bbed09fd'; - public function testHasDefaultProviderReturnsTrueWhenProviderExists(): void + public function testHasProviderReturnsTrueWhenConfiguredProviderExists(): void { - $providerCollection = new TranslationProviderCollection([ - 'default-provider' => new InMemoryTestProvider('default-provider') - ]); - - $resolver = new TranslationProviderResolver($providerCollection, 'default-provider', []); + $resolver = new TranslationProviderResolver( + new TranslationProviderCollection(['default-provider' => new InMemoryTestProvider('default-provider')]), + $this->createConfigurationResolver('default-provider') + ); - static::assertTrue($resolver->hasDefaultProvider()); + static::assertTrue($resolver->hasProvider()); } - public function testHasSalesChannelProviderThrowsOnInvalidUuid(): void + public function testHasProviderReturnsFalseWhenNothingConfigured(): void { - $resolver = new TranslationProviderResolver(new TranslationProviderCollection([]), null, []); + $resolver = new TranslationProviderResolver( + new TranslationProviderCollection([]), + $this->createConfigurationResolver(null) + ); - $this->expectException(InvalidArgumentException::class); + static::assertFalse($resolver->hasProvider()); + } + + public function testHasProviderReturnsFalseWhenConfiguredProviderIsNotRegistered(): void + { + $resolver = new TranslationProviderResolver( + new TranslationProviderCollection([]), + $this->createConfigurationResolver('missing-provider') + ); - $resolver->hasSalesChannelProvider('not-a-uuid'); + static::assertFalse($resolver->hasProvider()); } - public function testGetProviderPrefersSalesChannelProviderOverDefaultProvider(): void + public function testGetProviderReturnsConfiguredDefaultProvider(): void { - $salesChannelProvider = new InMemoryTestProvider('sales-channel-provider'); - $defaultProvider = new InMemoryTestProvider('default-provider'); + $provider = new InMemoryTestProvider('default-provider'); + $resolver = new TranslationProviderResolver( + new TranslationProviderCollection(['default-provider' => $provider]), + $this->createConfigurationResolver('default-provider') + ); + static::assertSame($provider, $resolver->getProvider()); + } + + public function testGetProviderResolvesSalesChannelScopedProvider(): void + { + $provider = new InMemoryTestProvider('sales-provider'); $resolver = new TranslationProviderResolver( - new TranslationProviderCollection([ - 'default-provider' => $defaultProvider, - 'sales-channel-provider' => $salesChannelProvider - ]), - 'default-provider', - [self::SALES_CHANNEL_ID => 'sales-channel-provider'] + new TranslationProviderCollection(['sales-provider' => $provider]), + $this->createConfigurationResolver('sales-provider') ); - static::assertSame($salesChannelProvider, $resolver->getProvider(self::SALES_CHANNEL_ID)); + static::assertSame($provider, $resolver->getProvider(self::SALES_CHANNEL_ID)); } - public function testGetProviderFallsBackToDefaultProvider(): void + public function testGetProviderThrowsWhenNoDefaultProviderConfigured(): void { - $defaultProvider = new InMemoryTestProvider('default-provider'); $resolver = new TranslationProviderResolver( - new TranslationProviderCollection(['default-provider' => $defaultProvider]), - 'default-provider', - [] + new TranslationProviderCollection([]), + $this->createConfigurationResolver(null) ); - static::assertSame($defaultProvider, $resolver->getProvider(self::SALES_CHANNEL_ID)); + $this->expectException(MissingDefaultProviderException::class); + + $resolver->getProvider(); } - public function testGetProviderThrowsIfNoProviderExists(): void + public function testGetProviderThrowsWhenNoSalesChannelProviderConfigured(): void { - $resolver = new TranslationProviderResolver(new TranslationProviderCollection([]), null, []); + $resolver = new TranslationProviderResolver( + new TranslationProviderCollection([]), + $this->createConfigurationResolver(null) + ); - $this->expectException(RuntimeException::class); + $this->expectException(MissingSalesChannelProviderException::class); $resolver->getProvider(self::SALES_CHANNEL_ID); } - public function testResetClearsSalesChannelProviderCache(): void + public function testGetProviderThrowsWhenConfiguredProviderIsNotRegistered(): void { - $provider = new InMemoryTestProvider('sales-channel-provider'); $resolver = new TranslationProviderResolver( - new TranslationProviderCollection(['sales-channel-provider' => $provider]), - null, - [self::SALES_CHANNEL_ID => 'sales-channel-provider'] + new TranslationProviderCollection([]), + $this->createConfigurationResolver('ghost-provider') ); - static::assertSame($provider, $resolver->getSalesChannelProvider(self::SALES_CHANNEL_ID)); - static::assertNotSame([], $this->getProviderCache($resolver)); - - $resolver->reset(); + $this->expectException(UnknownProviderException::class); - static::assertSame([], $this->getProviderCache($resolver)); + $resolver->getProvider(); } - private function getProviderCache(TranslationProviderResolver $resolver): array + private function createConfigurationResolver(?string $providerName): ConfigurationResolver { - $reflectionProperty = new ReflectionProperty($resolver, 'providers'); + $configurationResolver = $this->createStub(ConfigurationResolver::class); + $configurationResolver->method('getProviderName')->willReturn($providerName); - return $reflectionProperty->isInitialized($resolver) ? $reflectionProperty->getValue($resolver) : []; + return $configurationResolver; } } From 788865487d717a5a0310f8fafcf9fe4c0e0cebaa Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Mon, 10 Aug 2026 10:01:35 +0200 Subject: [PATCH 3/5] feat: added CI workflows/actions --- .github/action/phpunit/action.yaml | 75 +++++++++++++++++ .../shopware-version-matrix/action.yaml | 54 +++++++++++++ .github/workflows/action-phpunit.yml | 81 +++++++++++++++++++ .github/workflows/phpstan.yml | 59 ++++++++++++++ .../workflows/shopware-store-compliance.yml | 45 +++++++++++ .github/workflows/shopware-trunk-weekly.yml | 47 +++++++++++ .github/workflows/tests.yml | 47 +++++++++++ 7 files changed, 408 insertions(+) create mode 100644 .github/action/phpunit/action.yaml create mode 100644 .github/action/shopware-version-matrix/action.yaml create mode 100644 .github/workflows/action-phpunit.yml create mode 100644 .github/workflows/phpstan.yml create mode 100644 .github/workflows/shopware-store-compliance.yml create mode 100644 .github/workflows/shopware-trunk-weekly.yml create mode 100644 .github/workflows/tests.yml diff --git a/.github/action/phpunit/action.yaml b/.github/action/phpunit/action.yaml new file mode 100644 index 0000000..89d1e4c --- /dev/null +++ b/.github/action/phpunit/action.yaml @@ -0,0 +1,75 @@ +name: 'PHPUnit' +description: 'Setup Shopware with this extension and run the plugin unit test suite' + +inputs: + extension-name: + description: 'Shopware extension directory and plugin name' + required: true + shopware-github-token: + description: 'Token used for checking out Shopware' + required: true + php-version: + description: 'PHP version used by the Shopware setup action' + required: true + shopware-version: + description: 'Shopware version used by the Shopware setup action' + required: true + install: + description: 'Whether the Shopware setup action should install Shopware and activate the plugin' + required: false + default: 'true' + allow-insecure-versions: + description: 'Whether the Shopware setup action should allow insecure Shopware versions' + required: false + default: 'false' + +runs: + using: 'composite' + steps: + - name: 'Setup Shopware and Extension' + uses: shopware/github-actions/setup-extension@main + with: + extensionName: ${{ inputs.extension-name }} + shopware-github-token: ${{ inputs.shopware-github-token }} + phpVersion: ${{ inputs.php-version }} + shopwareVersion: ${{ inputs.shopware-version }} + node-version: 22 + skip-js-build: true + install: ${{ inputs.install }} + env: test + keep-composer-tools: true + allow-insecure-versions: ${{ inputs.allow-insecure-versions }} + extraRepositories: | + { + "${{ inputs.extension-name }}": { + "type": "path", + "url": "custom/plugins/${{ inputs.extension-name }}", + "symlink": true + } + } + + - name: 'Validate composer.json and composer.lock' + working-directory: custom/plugins/${{ inputs.extension-name }} + shell: bash + run: composer validate + + - name: 'Read PHPUnit version constraint' + id: phpunit-version + working-directory: custom/plugins/${{ inputs.extension-name }} + shell: bash + run: | + constraint=$(jq -r '.["require-dev"]["phpunit/phpunit"] // empty' composer.json) + if [ -z "$constraint" ]; then + echo 'Missing require-dev constraint for phpunit/phpunit in composer.json' + exit 1 + fi + echo "constraint=$constraint" >> "$GITHUB_OUTPUT" + + - name: 'Require PHPUnit' + shell: bash + run: composer require --dev "phpunit/phpunit:${{ steps.phpunit-version.outputs.constraint }}" --with-dependencies + + - name: 'Run Unit Tests' + working-directory: custom/plugins/${{ inputs.extension-name }} + shell: bash + run: ../../../vendor/bin/phpunit --testdox --testsuite unit diff --git a/.github/action/shopware-version-matrix/action.yaml b/.github/action/shopware-version-matrix/action.yaml new file mode 100644 index 0000000..ea9b28d --- /dev/null +++ b/.github/action/shopware-version-matrix/action.yaml @@ -0,0 +1,54 @@ +name: 'Get Shopware Build Matrix' +description: 'Build Shopware version matrix from endoflife.date (maintained releases) with a fixed minimum version' + +inputs: + minimum-version: + description: 'Minimum Shopware version that must always be included' + required: false + default: 'v6.7.1.0' + minimum-minor: + description: 'Minimum Shopware minor line to include (for example 7 for 6.7)' + required: false + default: '6.7' + +outputs: + matrix: + description: 'JSON array of Shopware versions' + value: ${{ steps.fetch-versions.outputs.matrix }} + +runs: + using: composite + steps: + - name: Fetch and Filter Versions + id: fetch-versions + shell: bash + run: | + set -euo pipefail + + MIN_VERSION='${{ inputs.minimum-version }}' + MIN_MINOR='${{ inputs.minimum-minor }}' + + if [[ "${MIN_VERSION}" != v* ]]; then + MIN_VERSION="v${MIN_VERSION}" + fi + + LATEST_VERSIONS=$( + curl -fsSL 'https://endoflife.date/api/v1/products/shopware' \ + | jq -r --argjson minMinor "${MIN_MINOR}" ' + .result.releases + | map( + select( + .isMaintained == true + and ((.name | tonumber) >= $minMinor) + ) + | .latest.name + ) | .[] ' \ + | sed 's/^/v/' + ) + + MATRIX=$( + printf '%s\n%s\n' "${MIN_VERSION}" "${LATEST_VERSIONS}" \ + | jq -Rsc 'split("\n") | map(select(length > 0))' + ) + + echo "matrix=${MATRIX}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/action-phpunit.yml b/.github/workflows/action-phpunit.yml new file mode 100644 index 0000000..a2cf381 --- /dev/null +++ b/.github/workflows/action-phpunit.yml @@ -0,0 +1,81 @@ +name: Reusable PHPUnit + +on: + workflow_call: + inputs: + extension-name: + description: 'Shopware extension directory and plugin name' + required: true + type: string + php-version: + description: 'PHP version used by the Shopware setup action' + required: true + type: string + shopware-version: + description: 'Shopware version used by the Shopware setup action' + required: true + type: string + install: + description: 'Whether the Shopware setup action should install Shopware and activate the plugin' + required: false + type: string + default: 'true' + allow-insecure-versions: + description: 'Whether the Shopware setup action should allow insecure Shopware versions' + required: false + type: string + default: 'false' + +jobs: + phpunit: + name: PHPUnit + runs-on: ubuntu-latest + + steps: + - name: 'Setup Shopware and Extension' + uses: shopware/github-actions/setup-extension@main + with: + extensionName: ${{ inputs.extension-name }} + shopware-github-token: ${{ github.token }} + phpVersion: ${{ inputs.php-version }} + shopwareVersion: ${{ inputs.shopware-version }} + node-version: 22 + skip-js-build: true + install: ${{ inputs.install }} + env: test + keep-composer-tools: true + allow-insecure-versions: ${{ inputs.allow-insecure-versions }} + extraRepositories: | + { + "${{ inputs.extension-name }}": { + "type": "path", + "url": "custom/plugins/${{ inputs.extension-name }}", + "symlink": true + } + } + + - name: 'Validate composer.json and composer.lock' + working-directory: custom/plugins/${{ inputs.extension-name }} + shell: bash + run: composer validate + + - name: 'Read PHPUnit version constraint' + id: phpunit-version + working-directory: custom/plugins/${{ inputs.extension-name }} + shell: bash + run: | + constraint=$(jq -r '.["require-dev"]["phpunit/phpunit"] // empty' composer.json) + if [ -z "$constraint" ]; then + echo 'Missing require-dev constraint for phpunit/phpunit in composer.json' + exit 1 + fi + echo "constraint=$constraint" >> "$GITHUB_OUTPUT" + + - name: 'Require PHPUnit' + shell: bash + run: composer require --dev "phpunit/phpunit:${{ steps.phpunit-version.outputs.constraint }}" --with-dependencies + + - name: 'Run Unit Tests' + working-directory: custom/plugins/${{ inputs.extension-name }} + shell: bash + run: ../../../vendor/bin/phpunit --testdox --testsuite unit diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..0fff119 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,59 @@ +name: PHPStan Matrix + +on: + push: + branches: + - main + - develop + paths: + - 'src/**' + pull_request: + branches: + - main + - develop + paths: + - 'src/**' + +jobs: + phpstan: + name: 'PHP ${{ matrix.phpVersion }} | Shopware ${{ matrix.shopwareVersion }}' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + phpVersion: ['8.3', '8.4'] + shopware: + - version: '6.7.1.0' + phpstanConfig: 'phpstan.shopware-6.7.1.0.neon.dist' + - version: '^6.7.1' + phpstanConfig: 'phpstan.neon.dist' + + steps: + - name: 'Checkout code' + uses: actions/checkout@v6 + + - name: 'Setup PHP' + uses: shivammathur/setup-php@v2 + with: + php-version: '${{ matrix.phpVersion }}' + tools: composer + coverage: 'xdebug3' + + - name: 'Validate composer.json and composer.lock' + shell: bash + run: composer validate + + - name: 'Set Shopware Version' + shell: bash + run: > + composer require --no-interaction --no-update + shopware/core:${{ matrix.shopware.version }} + + - name: 'Install Composer Dependencies' + shell: bash + run: > + composer install --prefer-dist --no-progress --no-interaction --optimize-autoloader + + - name: Run phpstan + shell: bash + run: vendor/bin/phpstan --configuration=${{ matrix.shopware.phpstanConfig }} --memory-limit=-1 --error-format=github diff --git a/.github/workflows/shopware-store-compliance.yml b/.github/workflows/shopware-store-compliance.yml new file mode 100644 index 0000000..1d82eb8 --- /dev/null +++ b/.github/workflows/shopware-store-compliance.yml @@ -0,0 +1,45 @@ +name: Shopware Store Compliance + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + extension-verifier: + name: Extension Verifier + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: 'Setup PHP' + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer + coverage: 'xdebug3' + + - name: 'Install Composer Dependencies' + shell: bash + run: composer install --prefer-dist --no-progress --no-interaction --optimize-autoloader + + # inspired by https://github.com/shopware/github-actions/blob/main/extension-verifier/action.yml + - name: Install Shopware-CLI + uses: shopware/shopware-cli-action@v1 + + - name: Run verifier + if: inputs.action == 'check' + shell: bash + run: shopware-cli extension validate --store-compliance . + + - name: Run formatter + if: inputs.action == 'format' + shell: bash + run: shopware-cli extension format . --dry-run diff --git a/.github/workflows/shopware-trunk-weekly.yml b/.github/workflows/shopware-trunk-weekly.yml new file mode 100644 index 0000000..68a43c2 --- /dev/null +++ b/.github/workflows/shopware-trunk-weekly.yml @@ -0,0 +1,47 @@ +name: Shopware Trunk Weekly + +on: + schedule: + - cron: '0 1 * * 1' + workflow_dispatch: + +jobs: + phpstan: + name: 'PHPStan' + runs-on: ubuntu-latest + + steps: + - name: 'Checkout code' + uses: actions/checkout@v6 + + - name: 'Setup PHP' + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer + coverage: 'xdebug3' + + - name: 'Set Shopware Version' + shell: bash + run: > + composer require --no-interaction --no-update + shopware/core:dev-trunk + shopware/storefront:dev-trunk + + - name: 'Install Composer Dependencies' + shell: bash + run: > + composer install --prefer-dist --no-progress --no-interaction --optimize-autoloader + + - name: Run phpstan + shell: bash + run: vendor/bin/phpstan --memory-limit=-1 --error-format=github + + phpunit: + name: 'PHPUnit' + uses: ./.github/workflows/action-phpunit.yml + with: + extension-name: ${{ github.event.repository.name }} + php-version: '8.4' + shopware-version: 'trunk' + install: 'true' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..32702e7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,47 @@ +name: Tests + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + version-matrix: + name: 'Generate Shopware version matrix' + runs-on: ubuntu-latest + outputs: + shopwareVersions: ${{ steps.shopware-version-matrix.outputs.matrix }} + + steps: + - name: 'Checkout Code' + uses: actions/checkout@v6 + + - uses: ./.github/action/shopware-version-matrix + id: shopware-version-matrix + with: + minimum-version: 'v6.7.1.0' + minimum-minor: '6.7' + + - name: 'Show generated matrix' + run: echo '${{ steps.shopware-version-matrix.outputs.matrix }}' + + phpunit: + needs: version-matrix + name: 'PHP ${{ matrix.phpVersion }} | Shopware ${{ matrix.shopwareVersion }}' + strategy: + fail-fast: false + matrix: + phpVersion: ['8.2', '8.3', '8.4'] + shopwareVersion: ${{ fromJson(needs.version-matrix.outputs.shopwareVersions) }} + uses: ./.github/workflows/action-phpunit.yml + with: + extension-name: ${{ github.event.repository.name }} + php-version: ${{ matrix.phpVersion }} + shopware-version: ${{ matrix.shopwareVersion }} + install: 'true' + allow-insecure-versions: 'true' From 2385fed01b98ceb61a916690f210303359eae1c3 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Mon, 10 Aug 2026 10:17:43 +0200 Subject: [PATCH 4/5] refactor: (phpstan) fixed phpstan errors --- src/Command/PushSnippetsCommand.php | 1 - src/Core/System/RelevantLocaleResolver.php | 1 - .../Snippet/SalesChannelTranslationRefresher.php | 10 +++++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Command/PushSnippetsCommand.php b/src/Command/PushSnippetsCommand.php index 3a29857..da8abdd 100644 --- a/src/Command/PushSnippetsCommand.php +++ b/src/Command/PushSnippetsCommand.php @@ -110,7 +110,6 @@ private function resolveLocales(InputInterface $input, SymfonyStyle $io): ?array { /** @var string[] $locales */ $locales = $input->getOption('locales'); - assert(is_array($locales)); if ($locales !== []) { $missingLocales = array_diff($locales, $this->relevantLocaleResolver->getAll()); diff --git a/src/Core/System/RelevantLocaleResolver.php b/src/Core/System/RelevantLocaleResolver.php index ff299ce..32af202 100644 --- a/src/Core/System/RelevantLocaleResolver.php +++ b/src/Core/System/RelevantLocaleResolver.php @@ -40,7 +40,6 @@ private function resolve(?string $salesChannelId = null): array if ($salesChannelLanguages === null) { continue; } - assert($salesChannelLanguages instanceof LanguageCollection); $languages->merge($salesChannelLanguages); } diff --git a/src/Core/System/Snippet/SalesChannelTranslationRefresher.php b/src/Core/System/Snippet/SalesChannelTranslationRefresher.php index 3d0b277..4e38d09 100644 --- a/src/Core/System/Snippet/SalesChannelTranslationRefresher.php +++ b/src/Core/System/Snippet/SalesChannelTranslationRefresher.php @@ -35,9 +35,13 @@ public function refresh(string ...$salesChannelIds): void $criteria->addAssociation('language.locale'); try { - $this->salesChannelDomainRepository->search($criteria, Context::createCLIContext())->map( - $this->warmUpTranslation(...) - ); + $domains = $this->salesChannelDomainRepository->search($criteria, Context::createCLIContext()); + foreach ($domains as $domain) { + if (!$domain instanceof SalesChannelDomainEntity) { + continue; + } + $this->warmUpTranslation($domain); + } } finally { $this->translator->resetInjection(); } From 37b4dcfdc53b7734e4a682ce91902ddfb5d5fa73 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Mon, 10 Aug 2026 10:28:26 +0200 Subject: [PATCH 5/5] refactor: (phpstan) fixed remaining phpstan notices/errors --- src/Command/PullSnippetsCommand.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Command/PullSnippetsCommand.php b/src/Command/PullSnippetsCommand.php index b6cdc71..18cde2e 100644 --- a/src/Command/PullSnippetsCommand.php +++ b/src/Command/PullSnippetsCommand.php @@ -17,7 +17,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Component\Translation\Writer\TranslationWriterInterface; @@ -233,9 +232,4 @@ private function resolveTranslationPath(): string { return rtrim($this->translatorDefaultPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . self::STORAGE_DIRECTORY; } - - private function ensureDirectoryExists(string $path): void - { - (new Filesystem())->mkdir($path); - } }