From b80769dbe1a1f4c89143a0959f378634c7816d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:28:04 +0200 Subject: [PATCH 1/9] Add unit and functional test suites - phpunit.xml.dist defines a "unit" suite (tests/Unit, no database) and a "functional" suite (tests/Functional, boots the test application). Functional tests run inside a transaction that dama/doctrine-test-bundle rolls back. - Unit tests (Prophecy) cover the DI extension/configuration/compiler pass, the model, context, provider, admin menu, fixture + example factory, form type, all Conversions API events and all event subscribers. - Functional tests cover PixelRepository::findEnabledByChannel(), the pixel fixture and the admin CRUD (list/create/validate/update/delete/auth) through the Symfony test client. - Test application: register DAMADoctrineTestBundle in the test environments, do not require built frontend assets in tests, and fix config/bootstrap.php to require_once the Composer autoloader (the previous require redeclared the autoloader under Infection, turning every mutant into a fatal error). - Small behaviour-preserving simplifications to make branches testable: main taxon fallback via getTaxons()->first(), integer cents divided by 100 without rounding, LimitIterator instead of a counter/break. - Infection now runs at 100% MSI; equivalent mutants (defensive string casts, type-narrowing asserts, trait method visibility) are ignored explicitly. Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- composer.json | 3 + infection.json.dist | 22 +- phpunit.xml.dist | 13 +- src/Event/FormatAmountTrait.php | 2 +- src/Event/ProductAddedToCartEvent.php | 16 +- src/Event/ProductViewedEvent.php | 17 +- .../ViewCategorySubscriber.php | 13 +- tests/Application/config/bootstrap.php | 2 +- tests/Application/config/bundles.php | 1 + .../config/packages/test/assets.yaml | 8 + .../config/packages/test/webpack_encore.yaml | 3 + tests/Functional/Admin/ManagingPixelsTest.php | 140 +++++++++++++ tests/Functional/Fixture/PixelFixtureTest.php | 44 ++++ tests/Functional/FunctionalTestCase.php | 142 +++++++++++++ .../Repository/PixelRepositoryTest.php | 52 +++++ tests/Unit/Context/PixelContextTest.php | 58 ++++++ .../OverrideDefaultPixelProviderPassTest.php | 46 +++++ .../DependencyInjection/ConfigurationTest.php | 76 +++++++ .../SetonoSyliusFacebookExtensionTest.php | 71 +++++++ tests/Unit/Event/CategoryViewedEventTest.php | 53 +++++ tests/Unit/Event/CheckoutStartedEventTest.php | 25 +++ tests/Unit/Event/OrderBasedEventTestCase.php | 80 ++++++++ tests/Unit/Event/OrderPlacedEventTest.php | 103 ++++++++++ .../Event/ProductAddedToCartEventTest.php | 185 +++++++++++++++++ tests/Unit/Event/ProductViewedEventTest.php | 74 +++++++ .../AddToCartSubscriberTest.php | 66 ++++++ .../EventSubscriberTestCase.php | 146 +++++++++++++ .../PurchaseSubscriberTest.php | 126 ++++++++++++ .../StartCheckoutSubscriberTest.php | 79 +++++++ .../ViewCategorySubscriberTest.php | 194 ++++++++++++++++++ .../ViewProductSubscriberTest.php | 48 +++++ .../Factory/PixelExampleFactoryTest.php | 93 +++++++++ tests/Unit/Fixture/PixelFixtureTest.php | 70 +++++++ tests/Unit/Form/Type/PixelTypeTest.php | 116 +++++++++++ tests/Unit/Menu/AdminMenuListenerTest.php | 83 ++++++++ tests/Unit/Model/PixelTest.php | 86 ++++++++ .../DoctrineBasedPixelProviderTest.php | 65 ++++++ 37 files changed, 2377 insertions(+), 44 deletions(-) create mode 100644 tests/Application/config/packages/test/assets.yaml create mode 100644 tests/Application/config/packages/test/webpack_encore.yaml create mode 100644 tests/Functional/Admin/ManagingPixelsTest.php create mode 100644 tests/Functional/Fixture/PixelFixtureTest.php create mode 100644 tests/Functional/FunctionalTestCase.php create mode 100644 tests/Functional/Repository/PixelRepositoryTest.php create mode 100644 tests/Unit/Context/PixelContextTest.php create mode 100644 tests/Unit/DependencyInjection/Compiler/OverrideDefaultPixelProviderPassTest.php create mode 100644 tests/Unit/DependencyInjection/ConfigurationTest.php create mode 100644 tests/Unit/DependencyInjection/SetonoSyliusFacebookExtensionTest.php create mode 100644 tests/Unit/Event/CategoryViewedEventTest.php create mode 100644 tests/Unit/Event/CheckoutStartedEventTest.php create mode 100644 tests/Unit/Event/OrderBasedEventTestCase.php create mode 100644 tests/Unit/Event/OrderPlacedEventTest.php create mode 100644 tests/Unit/Event/ProductAddedToCartEventTest.php create mode 100644 tests/Unit/Event/ProductViewedEventTest.php create mode 100644 tests/Unit/EventSubscriber/AddToCartSubscriberTest.php create mode 100644 tests/Unit/EventSubscriber/EventSubscriberTestCase.php create mode 100644 tests/Unit/EventSubscriber/PurchaseSubscriberTest.php create mode 100644 tests/Unit/EventSubscriber/StartCheckoutSubscriberTest.php create mode 100644 tests/Unit/EventSubscriber/ViewCategorySubscriberTest.php create mode 100644 tests/Unit/EventSubscriber/ViewProductSubscriberTest.php create mode 100644 tests/Unit/Fixture/Factory/PixelExampleFactoryTest.php create mode 100644 tests/Unit/Fixture/PixelFixtureTest.php create mode 100644 tests/Unit/Form/Type/PixelTypeTest.php create mode 100644 tests/Unit/Menu/AdminMenuListenerTest.php create mode 100644 tests/Unit/Model/PixelTest.php create mode 100644 tests/Unit/Provider/DoctrineBasedPixelProviderTest.php diff --git a/composer.json b/composer.json index 7095c6e..317df39 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "api-platform/core": "^2.7.16", "babdev/pagerfanta-bundle": "^3.8", "behat/behat": "^3.14", + "dama/doctrine-test-bundle": "^7.3", "doctrine/doctrine-bundle": "^2.11", "jms/serializer-bundle": "^4.2", "lexik/jwt-authentication-bundle": "^2.17", @@ -53,6 +54,8 @@ "setono/tag-bag-bundle": "^3.0", "shipmonk/composer-dependency-analyser": "^1.6", "sylius-labs/polyfill-symfony-security": "^1.1.2", + "symfony/browser-kit": "^6.4", + "symfony/css-selector": "^6.4", "symfony/debug-bundle": "^6.4", "symfony/dotenv": "^6.4", "symfony/http-client": "^6.4", diff --git a/infection.json.dist b/infection.json.dist index 6c54d3c..29d9201 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -12,5 +12,25 @@ } }, "minMsi": 100.00, - "minCoveredMsi": 100.00 + "minCoveredMsi": 100.00, + "mutators": { + "@default": true, + "CastString": { + "ignore": [ + "Setono\\SyliusFacebookPlugin\\Event\\*", + "Setono\\SyliusFacebookPlugin\\Provider\\DoctrineBasedPixelProvider::getPixels" + ] + }, + "MethodCallRemoval": { + "ignore": [ + "Setono\\SyliusFacebookPlugin\\Fixture\\Factory\\PixelExampleFactory::create", + "Setono\\SyliusFacebookPlugin\\Repository\\PixelRepository::findEnabledByChannel" + ] + }, + "ProtectedVisibility": { + "ignore": [ + "Setono\\SyliusFacebookPlugin\\Event\\FormatAmountTrait::formatAmount" + ] + } + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 06c44a7..b074a8d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,10 +8,19 @@ - - tests + + + tests/Unit + + + + tests/Functional + + + + diff --git a/src/Event/FormatAmountTrait.php b/src/Event/FormatAmountTrait.php index e8407f9..c7f04e1 100644 --- a/src/Event/FormatAmountTrait.php +++ b/src/Event/FormatAmountTrait.php @@ -8,6 +8,6 @@ trait FormatAmountTrait { protected static function formatAmount(int $amount): float { - return round($amount / 100, 2); + return $amount / 100; } } diff --git a/src/Event/ProductAddedToCartEvent.php b/src/Event/ProductAddedToCartEvent.php index 3c5ffb4..5d197c3 100644 --- a/src/Event/ProductAddedToCartEvent.php +++ b/src/Event/ProductAddedToCartEvent.php @@ -10,7 +10,6 @@ use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\OrderItemInterface; use Sylius\Component\Core\Model\ProductInterface; -use Sylius\Component\Taxonomy\Model\TaxonInterface; use Webmozart\Assert\Assert; final class ProductAddedToCartEvent extends Event @@ -78,19 +77,8 @@ private function populateProductInformation(ProductInterface $product): void private function getTaxonName(ProductInterface $product): ?string { - $taxon = $product->getMainTaxon(); - if (null !== $taxon) { - return $taxon->getName(); - } - - $taxons = $product->getTaxons(); - if ($taxons->isEmpty()) { - return null; - } - - $taxon = $taxons->first(); - Assert::isInstanceOf($taxon, TaxonInterface::class); + $taxon = $product->getMainTaxon() ?? $product->getTaxons()->first(); - return $taxon->getName(); + return false === $taxon ? null : $taxon->getName(); } } diff --git a/src/Event/ProductViewedEvent.php b/src/Event/ProductViewedEvent.php index 768d896..4e56354 100644 --- a/src/Event/ProductViewedEvent.php +++ b/src/Event/ProductViewedEvent.php @@ -6,8 +6,6 @@ use Setono\MetaConversionsApi\Event\Event; use Sylius\Component\Core\Model\ProductInterface; -use Sylius\Component\Taxonomy\Model\TaxonInterface; -use Webmozart\Assert\Assert; final class ProductViewedEvent extends Event { @@ -23,19 +21,8 @@ public function __construct(ProductInterface $product) private function getTaxonName(ProductInterface $product): ?string { - $taxon = $product->getMainTaxon(); - if (null !== $taxon) { - return $taxon->getName(); - } + $taxon = $product->getMainTaxon() ?? $product->getTaxons()->first(); - $taxons = $product->getTaxons(); - if ($taxons->isEmpty()) { - return null; - } - - $taxon = $taxons->first(); - Assert::isInstanceOf($taxon, TaxonInterface::class); - - return $taxon->getName(); + return false === $taxon ? null : $taxon->getName(); } } diff --git a/src/EventSubscriber/ViewCategorySubscriber.php b/src/EventSubscriber/ViewCategorySubscriber.php index 73c90ae..84d3104 100644 --- a/src/EventSubscriber/ViewCategorySubscriber.php +++ b/src/EventSubscriber/ViewCategorySubscriber.php @@ -4,6 +4,8 @@ namespace Setono\SyliusFacebookPlugin\EventSubscriber; +use IteratorIterator; +use LimitIterator; use Psr\EventDispatcher\EventDispatcherInterface; use Setono\SyliusFacebookPlugin\Event\CategoryViewedEvent; use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent; @@ -69,23 +71,14 @@ private function getProducts(ResourceGridView $gridView): array $codes = []; - $i = 0; - $max = 10; - /** @var mixed $datum */ - foreach ($data as $datum) { - if ($i >= $max) { - break; - } - + foreach (new LimitIterator(new IteratorIterator($data), 0, 10) as $datum) { if ($datum instanceof ProductInterface) { $code = $datum->getCode(); if (null !== $code) { $codes[] = $code; } } - - ++$i; } return $codes; diff --git a/tests/Application/config/bootstrap.php b/tests/Application/config/bootstrap.php index 2291ab4..2b005b0 100644 --- a/tests/Application/config/bootstrap.php +++ b/tests/Application/config/bootstrap.php @@ -4,7 +4,7 @@ use Symfony\Component\Dotenv\Dotenv; -require dirname(__DIR__) . '../../../vendor/autoload.php'; +require_once dirname(__DIR__, 3) . '/vendor/autoload.php'; // Load cached env vars if the .env.local.php file exists // Run "composer dump-env prod" to create it (requires symfony/flex >=1.2) diff --git a/tests/Application/config/bundles.php b/tests/Application/config/bundles.php index 379fbd2..f81e333 100644 --- a/tests/Application/config/bundles.php +++ b/tests/Application/config/bundles.php @@ -53,6 +53,7 @@ Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true, 'test_cached' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true, 'test_cached' => true], Sylius\Behat\Application\SyliusTestPlugin\SyliusTestPlugin::class => ['test' => true, 'test_cached' => true], + DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true, 'test_cached' => true], ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], Sylius\Bundle\ApiBundle\SyliusApiBundle::class => ['all' => true], diff --git a/tests/Application/config/packages/test/assets.yaml b/tests/Application/config/packages/test/assets.yaml new file mode 100644 index 0000000..cc35461 --- /dev/null +++ b/tests/Application/config/packages/test/assets.yaml @@ -0,0 +1,8 @@ +# The frontend is not built when running the test suite (e.g. in CI), so do not require the Webpack manifests +framework: + assets: + packages: + shop: + json_manifest_path: ~ + admin: + json_manifest_path: ~ diff --git a/tests/Application/config/packages/test/webpack_encore.yaml b/tests/Application/config/packages/test/webpack_encore.yaml new file mode 100644 index 0000000..049e0be --- /dev/null +++ b/tests/Application/config/packages/test/webpack_encore.yaml @@ -0,0 +1,3 @@ +# The frontend is not built when running the test suite (e.g. in CI), so do not fail on missing entrypoints +webpack_encore: + strict_mode: false diff --git a/tests/Functional/Admin/ManagingPixelsTest.php b/tests/Functional/Admin/ManagingPixelsTest.php new file mode 100644 index 0000000..266ff13 --- /dev/null +++ b/tests/Functional/Admin/ManagingPixelsTest.php @@ -0,0 +1,140 @@ +createAuthenticatedClient(); + self::createPixel('123456789', true, self::createChannel('WEB')); + + $client->request('GET', '/admin/facebook/pixels/'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('table', '123456789'); + } + + /** + * @test + */ + public function it_creates_a_pixel(): void + { + $client = $this->createAuthenticatedClient(); + self::createChannel('WEB'); + + $client->request('GET', '/admin/facebook/pixels/new'); + self::assertResponseIsSuccessful(); + + $client->submitForm('Create', [ + 'setono_sylius_facebook_pixel[pixelId]' => '123456789', + 'setono_sylius_facebook_pixel[accessToken]' => 'access_token', + 'setono_sylius_facebook_pixel[enabled]' => '1', + 'setono_sylius_facebook_pixel[channels]' => ['WEB'], + ]); + + self::assertResponseRedirects(); + $client->followRedirect(); + self::assertResponseIsSuccessful(); + + $pixel = self::getPixelRepository()->findOneBy(['pixelId' => '123456789']); + self::assertInstanceOf(PixelInterface::class, $pixel); + self::assertSame('access_token', $pixel->getAccessToken()); + self::assertTrue($pixel->isEnabled()); + self::assertCount(1, $pixel->getChannels()); + } + + /** + * @test + */ + public function it_does_not_create_a_pixel_with_an_invalid_pixel_id(): void + { + $client = $this->createAuthenticatedClient(); + self::createChannel('WEB'); + + $client->request('GET', '/admin/facebook/pixels/new'); + $client->submitForm('Create', [ + 'setono_sylius_facebook_pixel[pixelId]' => 'not a number', + 'setono_sylius_facebook_pixel[channels]' => ['WEB'], + ]); + + self::assertSelectorExists('.sylius-validation-error'); + self::assertNull(self::getPixelRepository()->findOneBy(['pixelId' => 'not a number'])); + } + + /** + * @test + */ + public function it_updates_a_pixel(): void + { + $client = $this->createAuthenticatedClient(); + $pixelId = (int) self::createPixel('123456789', true, self::createChannel('WEB'))->getId(); + + $client->request('GET', sprintf('/admin/facebook/pixels/%d/edit', $pixelId)); + self::assertResponseIsSuccessful(); + + $client->submitForm('Save changes', [ + 'setono_sylius_facebook_pixel[pixelId]' => '987654321', + 'setono_sylius_facebook_pixel[enabled]' => false, + ]); + + self::assertResponseRedirects(); + + // The kernel is rebooted between requests, so fetch the pixel again instead of refreshing a detached entity + self::getEntityManager()->clear(); + $pixel = self::getPixelRepository()->find($pixelId); + self::assertInstanceOf(PixelInterface::class, $pixel); + self::assertSame('987654321', $pixel->getPixelId()); + self::assertFalse($pixel->isEnabled()); + } + + /** + * @test + */ + public function it_deletes_a_pixel(): void + { + $client = $this->createAuthenticatedClient(); + $pixel = self::createPixel('123456789', true, self::createChannel('WEB')); + $pixelId = (int) $pixel->getId(); + + $client->request('GET', '/admin/facebook/pixels/'); + $client->submitForm('Delete'); + + self::assertResponseRedirects(); + self::getEntityManager()->clear(); + self::assertNull(self::getPixelRepository()->find($pixelId)); + } + + /** + * @test + */ + public function it_requires_an_authenticated_administrator(): void + { + $client = self::createClient(); + + $client->request('GET', '/admin/facebook/pixels/'); + + self::assertResponseRedirects('/admin/login'); + } + + private function createAuthenticatedClient(): KernelBrowser + { + $client = self::createClient(); + + $adminUser = self::createAdminUser(); + self::assertInstanceOf(UserInterface::class, $adminUser); + $client->loginUser($adminUser, 'admin'); + + return $client; + } +} diff --git a/tests/Functional/Fixture/PixelFixtureTest.php b/tests/Functional/Fixture/PixelFixtureTest.php new file mode 100644 index 0000000..e47c65d --- /dev/null +++ b/tests/Functional/Fixture/PixelFixtureTest.php @@ -0,0 +1,44 @@ +get('setono_sylius_facebook.fixture.pixel'); + self::assertInstanceOf(PixelFixture::class, $fixture); + + $fixture->load((new Processor())->process($fixture->getConfigTreeBuilder()->buildTree(), [[ + 'custom' => [ + ['pixel_id' => '123456789', 'access_token' => 'access_token', 'channels' => ['FASHION_WEB']], + ['pixel_id' => '987654321', 'access_token' => 'access_token', 'enabled' => false, 'channels' => ['FASHION_WEB']], + ], + ]])); + + $pixel = self::getPixelRepository()->findOneBy(['pixelId' => '123456789']); + self::assertInstanceOf(PixelInterface::class, $pixel); + self::assertTrue($pixel->isEnabled()); + self::assertSame('access_token', $pixel->getAccessToken()); + self::assertSame(['FASHION_WEB'], array_map(static fn ($channel) => $channel->getCode(), $pixel->getChannels()->toArray())); + + $disabledPixel = self::getPixelRepository()->findOneBy(['pixelId' => '987654321']); + self::assertInstanceOf(PixelInterface::class, $disabledPixel); + self::assertFalse($disabledPixel->isEnabled()); + + self::assertCount(1, self::getPixelRepository()->findEnabledByChannel($channel)); + } +} diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php new file mode 100644 index 0000000..839c2db --- /dev/null +++ b/tests/Functional/FunctionalTestCase.php @@ -0,0 +1,142 @@ +get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + return $entityManager; + } + + protected static function getPixelRepository(): PixelRepositoryInterface + { + $repository = static::getContainer()->get('setono_sylius_facebook.repository.pixel'); + self::assertInstanceOf(PixelRepositoryInterface::class, $repository); + + return $repository; + } + + protected static function createChannel(string $code): ChannelInterface + { + $locale = self::findOrCreateCodeAwareResource(LocaleInterface::class, 'sylius.repository.locale', 'sylius.factory.locale', 'en_US'); + $currency = self::findOrCreateCodeAwareResource(CurrencyInterface::class, 'sylius.repository.currency', 'sylius.factory.currency', 'USD'); + + $channel = self::createResource(ChannelInterface::class, 'sylius.factory.channel'); + $channel->setCode($code); + $channel->setName($code); + $channel->setEnabled(true); + $channel->setTaxCalculationStrategy('order_items_based'); + $channel->setDefaultLocale($locale); + $channel->addLocale($locale); + $channel->setBaseCurrency($currency); + $channel->addCurrency($currency); + + self::persist($channel); + + return $channel; + } + + protected static function createPixel(string $pixelId, bool $enabled, ChannelInterface ...$channels): PixelInterface + { + $pixel = self::createResource(PixelInterface::class, 'setono_sylius_facebook.factory.pixel'); + $pixel->setPixelId($pixelId); + $pixel->setAccessToken('access_token'); + $pixel->setEnabled($enabled); + foreach ($channels as $channel) { + $pixel->addChannel($channel); + } + + self::persist($pixel); + + return $pixel; + } + + protected static function createAdminUser(): AdminUserInterface + { + $adminUser = self::createResource(AdminUserInterface::class, 'sylius.factory.admin_user'); + $adminUser->setUsername('admin'); + $adminUser->setEmail('admin@example.com'); + $adminUser->setPlainPassword('admin'); + $adminUser->setLocaleCode('en_US'); + $adminUser->setEnabled(true); + $adminUser->addRole('ROLE_ADMINISTRATION_ACCESS'); + + self::persist($adminUser); + + return $adminUser; + } + + protected static function persist(object ...$entities): void + { + $entityManager = self::getEntityManager(); + foreach ($entities as $entity) { + $entityManager->persist($entity); + } + $entityManager->flush(); + } + + /** + * @template T of object + * + * @param class-string $class + * + * @return T + */ + protected static function createResource(string $class, string $factoryServiceId): object + { + $factory = static::getContainer()->get($factoryServiceId); + self::assertInstanceOf(FactoryInterface::class, $factory); + + $resource = $factory->createNew(); + self::assertInstanceOf($class, $resource); + + return $resource; + } + + /** + * @template T of CodeAwareInterface + * + * @param class-string $class + * + * @return T + */ + private static function findOrCreateCodeAwareResource(string $class, string $repositoryServiceId, string $factoryServiceId, string $code): CodeAwareInterface + { + $repository = static::getContainer()->get($repositoryServiceId); + self::assertInstanceOf(RepositoryInterface::class, $repository); + + $resource = $repository->findOneBy(['code' => $code]); + if (null === $resource) { + $resource = self::createResource($class, $factoryServiceId); + $resource->setCode($code); + + self::persist($resource); + } + + self::assertInstanceOf($class, $resource); + + return $resource; + } +} diff --git a/tests/Functional/Repository/PixelRepositoryTest.php b/tests/Functional/Repository/PixelRepositoryTest.php new file mode 100644 index 0000000..9eb75ba --- /dev/null +++ b/tests/Functional/Repository/PixelRepositoryTest.php @@ -0,0 +1,52 @@ +findEnabledByChannel($web))); + self::assertSame(['3000', '4000'], self::pixelIds(self::getPixelRepository()->findEnabledByChannel($mobile))); + } + + /** + * @test + */ + public function it_finds_no_pixels_for_a_channel_without_pixels(): void + { + $channel = self::createChannel('WEB'); + + self::assertSame([], self::getPixelRepository()->findEnabledByChannel($channel)); + } + + /** + * @param array $pixels + * + * @return list + */ + private static function pixelIds(array $pixels): array + { + $pixelIds = array_map(static fn (PixelInterface $pixel): string => (string) $pixel->getPixelId(), $pixels); + sort($pixelIds); + + return $pixelIds; + } +} diff --git a/tests/Unit/Context/PixelContextTest.php b/tests/Unit/Context/PixelContextTest.php new file mode 100644 index 0000000..dd8acfc --- /dev/null +++ b/tests/Unit/Context/PixelContextTest.php @@ -0,0 +1,58 @@ +prophesize(ChannelContextInterface::class); + $channelContext->getChannel()->willReturn($channel); + + $pixelRepository = $this->prophesize(PixelRepositoryInterface::class); + $pixelRepository->findEnabledByChannel($channel)->willReturn([$pixel])->shouldBeCalledOnce(); + + $pixelContext = new PixelContext($channelContext->reveal(), $pixelRepository->reveal()); + + self::assertSame([$pixel], $pixelContext->getPixels()); + self::assertSame([$pixel], $pixelContext->getPixels()); + self::assertTrue($pixelContext->hasPixels()); + } + + /** + * @test + */ + public function it_has_no_pixels_when_none_are_enabled_for_the_current_channel(): void + { + $channel = new Channel(); + + $channelContext = $this->prophesize(ChannelContextInterface::class); + $channelContext->getChannel()->willReturn($channel); + + $pixelRepository = $this->prophesize(PixelRepositoryInterface::class); + $pixelRepository->findEnabledByChannel($channel)->willReturn([]); + + $pixelContext = new PixelContext($channelContext->reveal(), $pixelRepository->reveal()); + + self::assertSame([], $pixelContext->getPixels()); + self::assertFalse($pixelContext->hasPixels()); + } +} diff --git a/tests/Unit/DependencyInjection/Compiler/OverrideDefaultPixelProviderPassTest.php b/tests/Unit/DependencyInjection/Compiler/OverrideDefaultPixelProviderPassTest.php new file mode 100644 index 0000000..f40d8fd --- /dev/null +++ b/tests/Unit/DependencyInjection/Compiler/OverrideDefaultPixelProviderPassTest.php @@ -0,0 +1,46 @@ +setDefinition( + 'setono_sylius_facebook.provider.doctrine_based_pixel_provider', + new Definition(DoctrineBasedPixelProvider::class), + ); + + (new OverrideDefaultPixelProviderPass())->process($container); + + self::assertTrue($container->hasAlias('setono_meta_conversions_api.pixel_provider.default')); + self::assertSame( + 'setono_sylius_facebook.provider.doctrine_based_pixel_provider', + (string) $container->getAlias('setono_meta_conversions_api.pixel_provider.default'), + ); + } + + /** + * @test + */ + public function it_does_nothing_when_the_doctrine_based_pixel_provider_is_not_defined(): void + { + $container = new ContainerBuilder(); + + (new OverrideDefaultPixelProviderPass())->process($container); + + self::assertFalse($container->hasAlias('setono_meta_conversions_api.pixel_provider.default')); + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..cc420cc --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,76 @@ +assertProcessedConfigurationEquals([[]], [ + 'resources' => [ + 'pixel' => [ + 'classes' => [ + 'model' => Pixel::class, + 'controller' => ResourceController::class, + 'repository' => PixelRepository::class, + 'factory' => Factory::class, + 'form' => PixelType::class, + ], + ], + ], + ]); + } + + /** + * @test + */ + public function it_allows_overriding_a_resource_class(): void + { + $this->assertProcessedConfigurationEquals([ + ['resources' => ['pixel' => ['classes' => ['model' => 'App\Entity\Pixel']]]], + ], [ + 'resources' => [ + 'pixel' => [ + 'classes' => [ + 'model' => 'App\Entity\Pixel', + 'controller' => ResourceController::class, + 'repository' => PixelRepository::class, + 'factory' => Factory::class, + 'form' => PixelType::class, + ], + ], + ], + ]); + } + + /** + * @test + */ + public function it_does_not_allow_an_empty_model_class(): void + { + $this->assertConfigurationIsInvalid([ + ['resources' => ['pixel' => ['classes' => ['model' => '']]]], + ], 'cannot contain an empty value'); + } +} diff --git a/tests/Unit/DependencyInjection/SetonoSyliusFacebookExtensionTest.php b/tests/Unit/DependencyInjection/SetonoSyliusFacebookExtensionTest.php new file mode 100644 index 0000000..1a09180 --- /dev/null +++ b/tests/Unit/DependencyInjection/SetonoSyliusFacebookExtensionTest.php @@ -0,0 +1,71 @@ +load(); + + $this->assertContainerBuilderHasParameter('setono_sylius_facebook.driver', 'doctrine/orm'); + $this->assertContainerBuilderHasParameter('setono_sylius_facebook.model.pixel.class', Pixel::class); + $this->assertContainerBuilderHasService('setono_sylius_facebook.repository.pixel', PixelRepository::class); + $this->assertContainerBuilderHasService('setono_sylius_facebook.factory.pixel'); + $this->assertContainerBuilderHasAlias('setono_sylius_facebook.manager.pixel'); + } + + /** + * @test + */ + public function it_uses_the_configured_model_class(): void + { + $this->load(['resources' => ['pixel' => ['classes' => ['model' => 'App\Entity\Pixel']]]]); + + $this->assertContainerBuilderHasParameter('setono_sylius_facebook.model.pixel.class', 'App\Entity\Pixel'); + } + + /** + * @test + */ + public function it_registers_services(): void + { + $this->load(); + + $this->assertContainerBuilderHasService('setono_sylius_facebook.context.pixel', PixelContext::class); + $this->assertContainerBuilderHasService('setono_sylius_facebook.provider.doctrine_based_pixel_provider', DoctrineBasedPixelProvider::class); + $this->assertContainerBuilderHasServiceDefinitionWithTag('setono_sylius_facebook.form.type.pixel', 'form.type'); + $this->assertContainerBuilderHasServiceDefinitionWithTag('setono_sylius_facebook.fixture.pixel', 'sylius_fixtures.fixture'); + $this->assertContainerBuilderHasServiceDefinitionWithTag(AdminMenuListener::class, 'kernel.event_listener', [ + 'event' => 'sylius.menu.admin.main', + 'method' => 'addAdminMenuItems', + ]); + + foreach (['add_to_cart', 'purchase', 'start_checkout', 'view_category', 'view_product'] as $subscriber) { + $this->assertContainerBuilderHasServiceDefinitionWithTag( + sprintf('setono_sylius_facebook.event_subscriber.%s', $subscriber), + 'kernel.event_subscriber', + ); + } + } +} diff --git a/tests/Unit/Event/CategoryViewedEventTest.php b/tests/Unit/Event/CategoryViewedEventTest.php new file mode 100644 index 0000000..14ac3ce --- /dev/null +++ b/tests/Unit/Event/CategoryViewedEventTest.php @@ -0,0 +1,53 @@ +createTaxon('Men', null); + $clothes = $this->createTaxon('Clothes', $men); + $tShirts = $this->createTaxon('T-Shirts', $clothes); + + $event = new CategoryViewedEvent($tShirts, ['T_SHIRT_1', 'T_SHIRT_2']); + + self::assertSame('ViewCategory', $event->eventName); + self::assertSame('product', $event->customData->contentType); + self::assertSame('T-Shirts', $event->customData->contentName); + self::assertSame(['T_SHIRT_1', 'T_SHIRT_2'], $event->customData->contentIds); + self::assertSame('Men > Clothes', $event->customData->contentCategory); + } + + /** + * @test + */ + public function it_has_an_empty_content_category_for_a_root_taxon(): void + { + $event = new CategoryViewedEvent($this->createTaxon('Men', null)); + + self::assertSame('', $event->customData->contentCategory); + self::assertSame([], $event->customData->contentIds); + } + + private function createTaxon(string $name, ?TaxonInterface $parent): TaxonInterface + { + $taxon = $this->prophesize(TaxonInterface::class); + $taxon->getName()->willReturn($name); + $taxon->getParent()->willReturn($parent); + + return $taxon->reveal(); + } +} diff --git a/tests/Unit/Event/CheckoutStartedEventTest.php b/tests/Unit/Event/CheckoutStartedEventTest.php new file mode 100644 index 0000000..969032a --- /dev/null +++ b/tests/Unit/Event/CheckoutStartedEventTest.php @@ -0,0 +1,25 @@ +createOrder([$this->createOrderItem('JEANS_M', 3, 1000, 3000)], 3000)); + + self::assertSame(Event::EVENT_INITIATE_CHECKOUT, $event->eventName); + self::assertSame('USD', $event->customData->currency); + self::assertSame(30.0, $event->customData->value); + self::assertSame(['JEANS_M'], $event->customData->contentIds); + self::assertSame(3, $event->customData->numItems); + } +} diff --git a/tests/Unit/Event/OrderBasedEventTestCase.php b/tests/Unit/Event/OrderBasedEventTestCase.php new file mode 100644 index 0000000..c30eb52 --- /dev/null +++ b/tests/Unit/Event/OrderBasedEventTestCase.php @@ -0,0 +1,80 @@ + $items + */ + protected function createOrder( + array $items = [], + int $total = 0, + ?CustomerInterface $customer = null, + ?AddressInterface $billingAddress = null, + ): OrderInterface { + $order = $this->prophesize(OrderInterface::class); + $order->getCurrencyCode()->willReturn('USD'); + $order->getTotal()->willReturn($total); + $order->getItems()->willReturn(new ArrayCollection($items)); + $order->getCustomer()->willReturn($customer); + $order->getBillingAddress()->willReturn($billingAddress); + + return $order->reveal(); + } + + protected function createOrderItem(string $variantCode, int $quantity, int $discountedUnitPrice, int $total): OrderItemInterface + { + $orderItem = $this->prophesize(OrderItemInterface::class); + $orderItem->getVariant()->willReturn($this->createProductVariant($variantCode)); + $orderItem->getQuantity()->willReturn($quantity); + $orderItem->getDiscountedUnitPrice()->willReturn($discountedUnitPrice); + $orderItem->getTotal()->willReturn($total); + + return $orderItem->reveal(); + } + + protected function createProductVariant(?string $code): ProductVariantInterface + { + $variant = $this->prophesize(ProductVariantInterface::class); + $variant->getCode()->willReturn($code); + + return $variant->reveal(); + } + + protected function createCustomer(?string $gender): CustomerInterface + { + $customer = $this->prophesize(CustomerInterface::class); + $customer->getEmailCanonical()->willReturn('john.doe@example.com'); + $customer->getPhoneNumber()->willReturn('+4512345678'); + $customer->getGender()->willReturn($gender); + + return $customer->reveal(); + } + + protected function createBillingAddress(): AddressInterface + { + $address = $this->prophesize(AddressInterface::class); + $address->getFirstName()->willReturn('John'); + $address->getLastName()->willReturn('Doe'); + $address->getPhoneNumber()->willReturn('+4587654321'); + $address->getPostcode()->willReturn('8000'); + $address->getCity()->willReturn('Aarhus'); + $address->getCountryCode()->willReturn('DK'); + + return $address->reveal(); + } +} diff --git a/tests/Unit/Event/OrderPlacedEventTest.php b/tests/Unit/Event/OrderPlacedEventTest.php new file mode 100644 index 0000000..aab5101 --- /dev/null +++ b/tests/Unit/Event/OrderPlacedEventTest.php @@ -0,0 +1,103 @@ +createOrder( + [ + $this->createOrderItem('JEANS_M', 2, 1000, 2000), + $this->createOrderItem('T_SHIRT_L', 1, 500, 500), + ], + 2500, + $this->createCustomer('m'), + $this->createBillingAddress(), + ); + + $event = new OrderPlacedEvent($order); + + self::assertSame(Event::EVENT_PURCHASE, $event->eventName); + self::assertSame('USD', $event->customData->currency); + self::assertSame(25.0, $event->customData->value); + self::assertSame('product', $event->customData->contentType); + self::assertSame(['JEANS_M', 'T_SHIRT_L'], $event->customData->contentIds); + self::assertEquals([new Content('JEANS_M', 2, 10.0), new Content('T_SHIRT_L', 1, 5.0)], $event->customData->contents); + self::assertSame(3, $event->customData->numItems); + + self::assertSame(['john.doe@example.com'], $event->userData->email); + self::assertSame(['+4512345678', '+4587654321'], $event->userData->phoneNumber); + self::assertSame(['m'], $event->userData->gender); + self::assertSame(['John'], $event->userData->firstName); + self::assertSame(['Doe'], $event->userData->lastName); + self::assertSame(['8000'], $event->userData->zipCode); + self::assertSame(['Aarhus'], $event->userData->city); + self::assertSame(['DK'], $event->userData->country); + } + + /** + * @test + */ + public function it_accepts_both_known_genders(): void + { + self::assertSame(['f'], (new OrderPlacedEvent($this->createOrder([], 0, $this->createCustomer('f'))))->userData->gender); + self::assertSame(['m'], (new OrderPlacedEvent($this->createOrder([], 0, $this->createCustomer('m'))))->userData->gender); + } + + /** + * @test + */ + public function it_ignores_unknown_genders(): void + { + $event = new OrderPlacedEvent($this->createOrder([], 0, $this->createCustomer('u'))); + + self::assertSame([], $event->userData->gender); + self::assertSame(['john.doe@example.com'], $event->userData->email); + } + + /** + * @test + */ + public function it_skips_items_without_a_variant(): void + { + $orderItem = $this->prophesize(\Sylius\Component\Core\Model\OrderItemInterface::class); + $orderItem->getVariant()->willReturn(null); + $orderItem->getQuantity()->willReturn(2); + + $event = new OrderPlacedEvent($this->createOrder([$orderItem->reveal(), $this->createOrderItem('JEANS_M', 1, 1000, 1000)], 1000)); + + self::assertSame(['JEANS_M'], $event->customData->contentIds); + self::assertEquals([new Content('JEANS_M', 1, 10.0)], $event->customData->contents); + self::assertSame(3, $event->customData->numItems); + } + + /** + * @test + */ + public function it_skips_items_without_a_variant_code_and_orders_without_customer_and_billing_address(): void + { + $orderItem = $this->prophesize(\Sylius\Component\Core\Model\OrderItemInterface::class); + $orderItem->getVariant()->willReturn($this->createProductVariant(null)); + $orderItem->getQuantity()->willReturn(1); + $orderItem->getDiscountedUnitPrice()->willReturn(100); + + $event = new OrderPlacedEvent($this->createOrder([$orderItem->reveal()], 100)); + + self::assertSame(1.0, $event->customData->value); + self::assertSame([], $event->customData->contentIds); + self::assertEquals([new Content('', 1, 1.0)], $event->customData->contents); + self::assertSame(1, $event->customData->numItems); + self::assertSame([], $event->userData->email); + self::assertSame([], $event->userData->firstName); + } +} diff --git a/tests/Unit/Event/ProductAddedToCartEventTest.php b/tests/Unit/Event/ProductAddedToCartEventTest.php new file mode 100644 index 0000000..0098432 --- /dev/null +++ b/tests/Unit/Event/ProductAddedToCartEventTest.php @@ -0,0 +1,185 @@ +createOrder([$this->createOrderItem('JEANS_M', 3, 1000, 3000)]); + $addedOrderItem = $this->createAddedOrderItem('JEANS_M', 2, $this->createProduct($this->createTaxon('Clothes'))); + + $event = new ProductAddedToCartEvent($order, $addedOrderItem); + + self::assertSame(Event::EVENT_ADD_TO_CART, $event->eventName); + self::assertSame('USD', $event->customData->currency); + self::assertSame(20.0, $event->customData->value); + self::assertSame('product', $event->customData->contentType); + self::assertSame('Jeans', $event->customData->contentName); + self::assertSame(['JEANS'], $event->customData->contentIds); + self::assertSame('Clothes', $event->customData->contentCategory); + self::assertEquals([new Content('JEANS', 2, 10.0)], $event->customData->contents); + } + + /** + * @test + */ + public function it_has_no_product_information_when_the_order_item_has_no_product(): void + { + $order = $this->createOrder([$this->createOrderItem('JEANS_M', 1, 1000, 1000)]); + + $event = new ProductAddedToCartEvent($order, $this->createAddedOrderItem('JEANS_M', 1, null)); + + self::assertSame(10.0, $event->customData->value); + self::assertNull($event->customData->contentName); + self::assertSame([], $event->customData->contentIds); + self::assertSame([], $event->customData->contents); + } + + /** + * @test + */ + public function it_rounds_the_unit_price_to_the_nearest_cent(): void + { + // 1000 / 3 = 333.33 → rounds down to 333 + $order = $this->createOrder([$this->createOrderItem('JEANS_M', 3, 333, 1000)]); + $event = new ProductAddedToCartEvent($order, $this->createAddedOrderItem('JEANS_M', 1, $this->createProduct())); + + self::assertSame(3.33, $event->customData->value); + self::assertEquals([new Content('JEANS', 1, 3.33)], $event->customData->contents); + + // 2000 / 3 = 666.67 → rounds up to 667 + $order = $this->createOrder([$this->createOrderItem('JEANS_M', 3, 667, 2000)]); + $event = new ProductAddedToCartEvent($order, $this->createAddedOrderItem('JEANS_M', 1, $this->createProduct())); + + self::assertSame(6.67, $event->customData->value); + self::assertEquals([new Content('JEANS', 1, 6.67)], $event->customData->contents); + } + + /** + * @test + */ + public function it_requires_the_added_order_item_to_have_a_variant(): void + { + $order = $this->createOrder([$this->createOrderItem('JEANS_M', 1, 1000, 1000)]); + + $addedOrderItem = $this->prophesize(OrderItemInterface::class); + $addedOrderItem->getVariant()->willReturn(null); + + $this->expectException(\InvalidArgumentException::class); + + new ProductAddedToCartEvent($order, $addedOrderItem->reveal()); + } + + /** + * @test + */ + public function it_requires_the_added_order_item_variant_to_have_a_code(): void + { + $order = $this->createOrder([$this->createOrderItem('JEANS_M', 1, 1000, 1000)]); + + $addedOrderItem = $this->prophesize(OrderItemInterface::class); + $addedOrderItem->getVariant()->willReturn($this->createProductVariant(null)); + + $this->expectException(\InvalidArgumentException::class); + + new ProductAddedToCartEvent($order, $addedOrderItem->reveal()); + } + + /** + * @test + */ + public function it_requires_the_order_items_to_have_a_variant(): void + { + $orderItem = $this->prophesize(OrderItemInterface::class); + $orderItem->getVariant()->willReturn(null); + + $this->expectException(\InvalidArgumentException::class); + + new ProductAddedToCartEvent($this->createOrder([$orderItem->reveal()]), $this->createAddedOrderItem('JEANS_M', 1, $this->createProduct())); + } + + /** + * @test + */ + public function it_throws_when_the_added_order_item_is_not_on_the_order(): void + { + $order = $this->createOrder([$this->createOrderItem('T_SHIRT_L', 1, 500, 500)]); + + $this->expectException(LogicException::class); + + new ProductAddedToCartEvent($order, $this->createAddedOrderItem('JEANS_M', 1, $this->createProduct())); + } + + /** + * @test + */ + public function it_falls_back_to_the_first_taxon_when_the_product_has_no_main_taxon(): void + { + $order = $this->createOrder([$this->createOrderItem('JEANS_M', 1, 1000, 1000)]); + $product = $this->createProduct(null, [$this->createTaxon('Clothes'), $this->createTaxon('Jeans')]); + + $event = new ProductAddedToCartEvent($order, $this->createAddedOrderItem('JEANS_M', 1, $product)); + + self::assertSame('Clothes', $event->customData->contentCategory); + } + + /** + * @test + */ + public function it_has_no_content_category_when_the_product_has_no_taxons(): void + { + $order = $this->createOrder([$this->createOrderItem('JEANS_M', 1, 1000, 1000)]); + + $event = new ProductAddedToCartEvent($order, $this->createAddedOrderItem('JEANS_M', 1, $this->createProduct())); + + self::assertNull($event->customData->contentCategory); + } + + private function createAddedOrderItem(string $variantCode, int $quantity, ?ProductInterface $product): OrderItemInterface + { + $orderItem = $this->prophesize(OrderItemInterface::class); + $orderItem->getVariant()->willReturn($this->createProductVariant($variantCode)); + $orderItem->getQuantity()->willReturn($quantity); + $orderItem->getProduct()->willReturn($product); + + return $orderItem->reveal(); + } + + /** + * @param list $taxons + */ + private function createProduct(?TaxonInterface $mainTaxon = null, array $taxons = []): ProductInterface + { + $product = $this->prophesize(ProductInterface::class); + $product->getName()->willReturn('Jeans'); + $product->getCode()->willReturn('JEANS'); + $product->getMainTaxon()->willReturn($mainTaxon); + $product->getTaxons()->willReturn(new ArrayCollection($taxons)); + + return $product->reveal(); + } + + private function createTaxon(string $name): TaxonInterface + { + $taxon = $this->prophesize(TaxonInterface::class); + $taxon->getName()->willReturn($name); + + return $taxon->reveal(); + } +} diff --git a/tests/Unit/Event/ProductViewedEventTest.php b/tests/Unit/Event/ProductViewedEventTest.php new file mode 100644 index 0000000..e7f8bbd --- /dev/null +++ b/tests/Unit/Event/ProductViewedEventTest.php @@ -0,0 +1,74 @@ +createProduct($this->createTaxon('Jeans'))); + + self::assertSame(Event::EVENT_VIEW_CONTENT, $event->eventName); + self::assertSame('product', $event->customData->contentType); + self::assertSame('Office grey jeans', $event->customData->contentName); + self::assertSame(['OFFICE_GREY_JEANS'], $event->customData->contentIds); + self::assertSame('Jeans', $event->customData->contentCategory); + } + + /** + * @test + */ + public function it_falls_back_to_the_first_taxon_when_the_product_has_no_main_taxon(): void + { + $event = new ProductViewedEvent($this->createProduct(null, [$this->createTaxon('Clothes'), $this->createTaxon('Jeans')])); + + self::assertSame('Clothes', $event->customData->contentCategory); + } + + /** + * @test + */ + public function it_has_no_content_category_when_the_product_has_no_taxons(): void + { + $event = new ProductViewedEvent($this->createProduct(null)); + + self::assertNull($event->customData->contentCategory); + } + + /** + * @param list $taxons + */ + private function createProduct(?TaxonInterface $mainTaxon, array $taxons = []): ProductInterface + { + $product = $this->prophesize(ProductInterface::class); + $product->getName()->willReturn('Office grey jeans'); + $product->getCode()->willReturn('OFFICE_GREY_JEANS'); + $product->getMainTaxon()->willReturn($mainTaxon); + $product->getTaxons()->willReturn(new ArrayCollection($taxons)); + + return $product->reveal(); + } + + private function createTaxon(string $name): TaxonInterface + { + $taxon = $this->prophesize(TaxonInterface::class); + $taxon->getName()->willReturn($name); + + return $taxon->reveal(); + } +} diff --git a/tests/Unit/EventSubscriber/AddToCartSubscriberTest.php b/tests/Unit/EventSubscriber/AddToCartSubscriberTest.php new file mode 100644 index 0000000..68e869b --- /dev/null +++ b/tests/Unit/EventSubscriber/AddToCartSubscriberTest.php @@ -0,0 +1,66 @@ + 'track'], AddToCartSubscriber::getSubscribedEvents()); + } + + /** + * @test + */ + public function it_raises_a_product_added_to_cart_event(): void + { + $this->expectConversionsApiEvent(ProductAddedToCartEvent::class); + + $cart = $this->createOrder([$this->createOrderItem('JEANS_M', 2, $this->createProduct())]); + $addedOrderItem = $this->createOrderItem('JEANS_M', 1, $this->createProduct()); + + $this->createSubscriber($cart)->track(new ResourceControllerEvent($addedOrderItem)); + } + + /** + * @test + */ + public function it_logs_an_error_instead_of_failing_when_the_subject_is_not_an_order_item(): void + { + $this->expectLoggedError('Expected an instance of Sylius\Component\Core\Model\OrderItemInterface'); + + $this->createSubscriber($this->createOrder())->track(new ResourceControllerEvent(new \stdClass())); + } + + /** + * @test + */ + public function it_logs_an_error_instead_of_failing_when_the_cart_is_not_a_core_order(): void + { + $this->expectLoggedError('Expected an instance of Sylius\Component\Core\Model\OrderInterface'); + + $this->createSubscriber($this->createNonCoreOrder())->track(new ResourceControllerEvent($this->createOrderItem('JEANS_M', 1))); + } + + private function createSubscriber(BaseOrderInterface $cart): AddToCartSubscriber + { + $cartContext = $this->prophesize(CartContextInterface::class); + $cartContext->getCart()->willReturn($cart); + + $subscriber = new AddToCartSubscriber($this->eventDispatcher->reveal(), $cartContext->reveal()); + $subscriber->setLogger($this->logger->reveal()); + + return $subscriber; + } +} diff --git a/tests/Unit/EventSubscriber/EventSubscriberTestCase.php b/tests/Unit/EventSubscriber/EventSubscriberTestCase.php new file mode 100644 index 0000000..bbaa619 --- /dev/null +++ b/tests/Unit/EventSubscriber/EventSubscriberTestCase.php @@ -0,0 +1,146 @@ + */ + protected ObjectProphecy $eventDispatcher; + + /** @var ObjectProphecy */ + protected ObjectProphecy $logger; + + protected function setUp(): void + { + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + } + + /** + * @param class-string $eventClass + */ + protected function expectConversionsApiEvent(string $eventClass): void + { + $this->eventDispatcher + ->dispatch(Argument::that(static fn (object $event): bool => $event instanceof ConversionsApiEventRaised && $event->event instanceof $eventClass)) + ->shouldBeCalledOnce() + ->willReturnArgument(0) + ; + $this->logger->error(Argument::cetera())->shouldNotBeCalled(); + } + + protected function expectNoConversionsApiEvent(): void + { + $this->eventDispatcher->dispatch(Argument::any())->shouldNotBeCalled(); + $this->logger->error(Argument::cetera())->shouldNotBeCalled(); + } + + protected function expectLoggedError(string $messageContaining): void + { + $this->eventDispatcher->dispatch(Argument::any())->shouldNotBeCalled(); + $this->logger->error(Argument::containingString($messageContaining))->shouldBeCalledOnce(); + } + + /** + * Returns an order that is not a Sylius core order + */ + protected function createNonCoreOrder(): BaseOrderInterface + { + $order = $this->prophesize(BaseOrderInterface::class); + $order->isEmpty()->willReturn(false); + + return $order->reveal(); + } + + /** + * @param array $attributes + */ + protected function createRequestEvent(array $attributes, ?Session $session = null, int $requestType = HttpKernelInterface::MAIN_REQUEST): RequestEvent + { + $request = new Request([], [], $attributes); + if (null !== $session) { + $request->setSession($session); + } + + return new RequestEvent($this->prophesize(HttpKernelInterface::class)->reveal(), $request, $requestType); + } + + /** + * @param array $values + */ + protected function createSession(array $values = []): Session + { + $session = new Session(new MockArraySessionStorage()); + foreach ($values as $key => $value) { + $session->set($key, $value); + } + + return $session; + } + + /** + * @param list $items + */ + protected function createOrder(array $items = [], bool $empty = false): OrderInterface + { + $order = $this->prophesize(OrderInterface::class); + $order->getCurrencyCode()->willReturn('USD'); + $order->getTotal()->willReturn(1000); + $order->getItems()->willReturn(new ArrayCollection($items)); + $order->isEmpty()->willReturn($empty); + $order->getCustomer()->willReturn(null); + $order->getBillingAddress()->willReturn(null); + + return $order->reveal(); + } + + protected function createOrderItem(string $variantCode, int $quantity, ?ProductInterface $product = null): OrderItemInterface + { + $variant = $this->prophesize(ProductVariantInterface::class); + $variant->getCode()->willReturn($variantCode); + + $orderItem = $this->prophesize(OrderItemInterface::class); + $orderItem->getVariant()->willReturn($variant->reveal()); + $orderItem->getQuantity()->willReturn($quantity); + $orderItem->getDiscountedUnitPrice()->willReturn(1000); + $orderItem->getTotal()->willReturn(1000 * $quantity); + $orderItem->getProduct()->willReturn($product); + + return $orderItem->reveal(); + } + + protected function createProduct(): ProductInterface + { + $product = $this->prophesize(ProductInterface::class); + $product->getName()->willReturn('Jeans'); + $product->getCode()->willReturn('JEANS'); + $product->getMainTaxon()->willReturn(null); + $product->getTaxons()->willReturn(new ArrayCollection([])); + + return $product->reveal(); + } +} diff --git a/tests/Unit/EventSubscriber/PurchaseSubscriberTest.php b/tests/Unit/EventSubscriber/PurchaseSubscriberTest.php new file mode 100644 index 0000000..2b93782 --- /dev/null +++ b/tests/Unit/EventSubscriber/PurchaseSubscriberTest.php @@ -0,0 +1,126 @@ +> */ + private ObjectProphecy $orderRepository; + + protected function setUp(): void + { + parent::setUp(); + + /** @var ObjectProphecy> $orderRepository */ + $orderRepository = $this->prophesize(OrderRepositoryInterface::class); + $this->orderRepository = $orderRepository; + } + + /** + * @test + */ + public function it_subscribes_to_the_kernel_request_event(): void + { + self::assertSame([KernelEvents::REQUEST => 'track'], PurchaseSubscriber::getSubscribedEvents()); + } + + /** + * @test + */ + public function it_raises_an_order_placed_event_on_the_thank_you_page(): void + { + $this->expectConversionsApiEvent(OrderPlacedEvent::class); + $this->orderRepository->find(42)->willReturn($this->createOrder()); + + $this->createSubscriber()->track($this->createRequestEvent( + ['_route' => 'sylius_shop_order_thank_you'], + $this->createSession(['sylius_order_id' => 42]), + )); + } + + /** + * @test + */ + public function it_does_nothing_for_sub_requests(): void + { + $this->expectNoConversionsApiEvent(); + + $this->createSubscriber()->track($this->createRequestEvent( + ['_route' => 'sylius_shop_order_thank_you'], + $this->createSession(['sylius_order_id' => 42]), + HttpKernelInterface::SUB_REQUEST, + )); + } + + /** + * @test + */ + public function it_does_nothing_on_other_routes(): void + { + $this->expectNoConversionsApiEvent(); + + $this->createSubscriber()->track($this->createRequestEvent( + ['_route' => 'sylius_shop_homepage'], + $this->createSession(['sylius_order_id' => 42]), + )); + } + + /** + * @test + */ + public function it_does_nothing_when_the_session_has_no_order_id(): void + { + $this->expectNoConversionsApiEvent(); + + $this->createSubscriber()->track($this->createRequestEvent( + ['_route' => 'sylius_shop_order_thank_you'], + $this->createSession(), + )); + } + + /** + * @test + */ + public function it_does_nothing_when_the_order_does_not_exist(): void + { + $this->expectNoConversionsApiEvent(); + $this->orderRepository->find(42)->willReturn(null); + + $this->createSubscriber()->track($this->createRequestEvent( + ['_route' => 'sylius_shop_order_thank_you'], + $this->createSession(['sylius_order_id' => 42]), + )); + } + + /** + * @test + */ + public function it_does_nothing_when_the_order_is_not_a_core_order(): void + { + $this->expectNoConversionsApiEvent(); + $this->orderRepository->find(42)->willReturn($this->createNonCoreOrder()); + + $this->createSubscriber()->track($this->createRequestEvent( + ['_route' => 'sylius_shop_order_thank_you'], + $this->createSession(['sylius_order_id' => 42]), + )); + } + + private function createSubscriber(): PurchaseSubscriber + { + $subscriber = new PurchaseSubscriber($this->eventDispatcher->reveal(), $this->orderRepository->reveal()); + $subscriber->setLogger($this->logger->reveal()); + + return $subscriber; + } +} diff --git a/tests/Unit/EventSubscriber/StartCheckoutSubscriberTest.php b/tests/Unit/EventSubscriber/StartCheckoutSubscriberTest.php new file mode 100644 index 0000000..32ffa34 --- /dev/null +++ b/tests/Unit/EventSubscriber/StartCheckoutSubscriberTest.php @@ -0,0 +1,79 @@ + 'track'], StartCheckoutSubscriber::getSubscribedEvents()); + } + + /** + * @test + */ + public function it_raises_a_checkout_started_event_when_the_checkout_starts_with_a_non_empty_cart(): void + { + $this->expectConversionsApiEvent(CheckoutStartedEvent::class); + + $this->createSubscriber($this->createOrder([$this->createOrderItem('JEANS_M', 1)])) + ->track($this->createRequestEvent(['_route' => 'sylius_shop_checkout_start'])); + } + + /** + * @test + */ + public function it_does_nothing_when_the_cart_is_empty(): void + { + $this->expectNoConversionsApiEvent(); + + $this->createSubscriber($this->createOrder([], true)) + ->track($this->createRequestEvent(['_route' => 'sylius_shop_checkout_start'])); + } + + /** + * @test + */ + public function it_does_nothing_on_other_routes_and_sub_requests(): void + { + $this->expectNoConversionsApiEvent(); + + $subscriber = $this->createSubscriber($this->createOrder([$this->createOrderItem('JEANS_M', 1)])); + $subscriber->track($this->createRequestEvent(['_route' => 'sylius_shop_cart_summary'])); + $subscriber->track($this->createRequestEvent(['_route' => 'sylius_shop_checkout_start'], null, HttpKernelInterface::SUB_REQUEST)); + } + + /** + * @test + */ + public function it_logs_an_error_instead_of_failing_when_the_cart_is_not_a_core_order(): void + { + $this->expectLoggedError('Expected an instance of Sylius\Component\Core\Model\OrderInterface'); + + $this->createSubscriber($this->createNonCoreOrder()) + ->track($this->createRequestEvent(['_route' => 'sylius_shop_checkout_start'])); + } + + private function createSubscriber(BaseOrderInterface $cart): StartCheckoutSubscriber + { + $cartContext = $this->prophesize(CartContextInterface::class); + $cartContext->getCart()->willReturn($cart); + + $subscriber = new StartCheckoutSubscriber($this->eventDispatcher->reveal(), $cartContext->reveal()); + $subscriber->setLogger($this->logger->reveal()); + + return $subscriber; + } +} diff --git a/tests/Unit/EventSubscriber/ViewCategorySubscriberTest.php b/tests/Unit/EventSubscriber/ViewCategorySubscriberTest.php new file mode 100644 index 0000000..a6b0675 --- /dev/null +++ b/tests/Unit/EventSubscriber/ViewCategorySubscriberTest.php @@ -0,0 +1,194 @@ +> */ + private ObjectProphecy $taxonRepository; + + protected function setUp(): void + { + parent::setUp(); + + /** @var ObjectProphecy> $taxonRepository */ + $taxonRepository = $this->prophesize(TaxonRepositoryInterface::class); + $this->taxonRepository = $taxonRepository; + } + + /** + * @test + */ + public function it_subscribes_to_the_product_index_event(): void + { + self::assertSame(['sylius.product.index' => 'track'], ViewCategorySubscriber::getSubscribedEvents()); + } + + /** + * @test + */ + public function it_raises_a_category_viewed_event_with_the_first_products_of_the_taxon(): void + { + $this->taxonRepository->findOneBySlug('t-shirts', 'en_US')->willReturn($this->createTaxon()); + $this->eventDispatcher + ->dispatch(Argument::that(static function (object $event): bool { + if (!$event instanceof \Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised) { + return false; + } + + return $event->event instanceof CategoryViewedEvent && ['T_SHIRT_1', 'T_SHIRT_2'] === $event->event->customData->contentIds; + })) + ->shouldBeCalledOnce() + ->willReturnArgument(0) + ; + + $this->createSubscriber()->track(new ResourceControllerEvent($this->createGridView('t-shirts', [ + $this->createProductWithCode('T_SHIRT_1'), + new \stdClass(), + $this->createProductWithCode(null), + $this->createProductWithCode('T_SHIRT_2'), + ]))); + } + + /** + * @test + */ + public function it_only_includes_the_first_ten_products(): void + { + $this->taxonRepository->findOneBySlug('t-shirts', 'en_US')->willReturn($this->createTaxon()); + $this->eventDispatcher + ->dispatch(Argument::that(static function (object $event): bool { + if (!$event instanceof \Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised) { + return false; + } + + return $event->event instanceof CategoryViewedEvent && 10 === count($event->event->customData->contentIds) && 'T_SHIRT_10' === end($event->event->customData->contentIds); + })) + ->shouldBeCalledOnce() + ->willReturnArgument(0) + ; + + $products = []; + for ($i = 1; $i <= 12; ++$i) { + $products[] = $this->createProductWithCode(sprintf('T_SHIRT_%d', $i)); + } + + $this->createSubscriber()->track(new ResourceControllerEvent($this->createGridView('t-shirts', $products))); + } + + /** + * @test + */ + public function it_raises_a_category_viewed_event_without_products_when_the_grid_data_is_not_traversable(): void + { + $this->taxonRepository->findOneBySlug('t-shirts', 'en_US')->willReturn($this->createTaxon()); + $this->eventDispatcher + ->dispatch(Argument::that(static fn (object $event): bool => $event instanceof \Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised && + $event->event instanceof CategoryViewedEvent && + [] === $event->event->customData->contentIds)) + ->shouldBeCalledOnce() + ->willReturnArgument(0) + ; + $this->logger->error(Argument::cetera())->shouldNotBeCalled(); + + $requestConfiguration = $this->prophesize(RequestConfiguration::class); + $requestConfiguration->getRequest()->willReturn(new Request([], [], ['slug' => 't-shirts'])); + + $gridView = $this->prophesize(ResourceGridView::class); + $gridView->getRequestConfiguration()->willReturn($requestConfiguration->reveal()); + $gridView->getData()->willReturn(null); + + $this->createSubscriber()->track(new ResourceControllerEvent($gridView->reveal())); + } + + /** + * @test + */ + public function it_does_nothing_when_the_subject_is_not_a_grid_view(): void + { + $this->expectNoConversionsApiEvent(); + + $this->createSubscriber()->track(new ResourceControllerEvent(new \stdClass())); + } + + /** + * @test + */ + public function it_does_nothing_when_the_request_has_no_taxon_slug(): void + { + $this->expectNoConversionsApiEvent(); + $this->taxonRepository->findOneBySlug(Argument::cetera())->shouldNotBeCalled(); + + $this->createSubscriber()->track(new ResourceControllerEvent($this->createGridView(null, []))); + } + + /** + * @test + */ + public function it_does_nothing_when_the_taxon_does_not_exist(): void + { + $this->expectNoConversionsApiEvent(); + $this->taxonRepository->findOneBySlug('unknown', 'en_US')->willReturn(null); + + $this->createSubscriber()->track(new ResourceControllerEvent($this->createGridView('unknown', []))); + } + + private function createSubscriber(): ViewCategorySubscriber + { + $localeContext = $this->prophesize(LocaleContextInterface::class); + $localeContext->getLocaleCode()->willReturn('en_US'); + + $subscriber = new ViewCategorySubscriber($this->eventDispatcher->reveal(), $localeContext->reveal(), $this->taxonRepository->reveal()); + $subscriber->setLogger($this->logger->reveal()); + + return $subscriber; + } + + /** + * @param list $data + */ + private function createGridView(?string $slug, array $data): ResourceGridView + { + $requestConfiguration = $this->prophesize(RequestConfiguration::class); + $requestConfiguration->getRequest()->willReturn(new Request([], [], null === $slug ? [] : ['slug' => $slug])); + + $gridView = $this->prophesize(ResourceGridView::class); + $gridView->getRequestConfiguration()->willReturn($requestConfiguration->reveal()); + $gridView->getData()->willReturn(new ArrayIterator($data)); + + return $gridView->reveal(); + } + + private function createTaxon(): TaxonInterface + { + $taxon = $this->prophesize(TaxonInterface::class); + $taxon->getName()->willReturn('T-Shirts'); + $taxon->getParent()->willReturn(null); + + return $taxon->reveal(); + } + + private function createProductWithCode(?string $code): ProductInterface + { + $product = $this->prophesize(ProductInterface::class); + $product->getCode()->willReturn($code); + + return $product->reveal(); + } +} diff --git a/tests/Unit/EventSubscriber/ViewProductSubscriberTest.php b/tests/Unit/EventSubscriber/ViewProductSubscriberTest.php new file mode 100644 index 0000000..8ae97fc --- /dev/null +++ b/tests/Unit/EventSubscriber/ViewProductSubscriberTest.php @@ -0,0 +1,48 @@ + 'track'], ViewProductSubscriber::getSubscribedEvents()); + } + + /** + * @test + */ + public function it_raises_a_product_viewed_event(): void + { + $this->expectConversionsApiEvent(ProductViewedEvent::class); + + $this->createSubscriber()->track(new ResourceControllerEvent($this->createProduct())); + } + + /** + * @test + */ + public function it_logs_an_error_instead_of_failing_when_the_subject_is_not_a_product(): void + { + $this->expectLoggedError('Expected an instance of Sylius\Component\Core\Model\ProductInterface'); + + $this->createSubscriber()->track(new ResourceControllerEvent(new \stdClass())); + } + + private function createSubscriber(): ViewProductSubscriber + { + $subscriber = new ViewProductSubscriber($this->eventDispatcher->reveal()); + $subscriber->setLogger($this->logger->reveal()); + + return $subscriber; + } +} diff --git a/tests/Unit/Fixture/Factory/PixelExampleFactoryTest.php b/tests/Unit/Fixture/Factory/PixelExampleFactoryTest.php new file mode 100644 index 0000000..e566e79 --- /dev/null +++ b/tests/Unit/Fixture/Factory/PixelExampleFactoryTest.php @@ -0,0 +1,93 @@ +webChannel = new Channel(); + $this->webChannel->setCode('WEB'); + + $this->mobileChannel = new Channel(); + $this->mobileChannel->setCode('MOBILE'); + + /** @var ObjectProphecy> $pixelFactory */ + $pixelFactory = $this->prophesize(FactoryInterface::class); + $pixelFactory->createNew()->will(static fn (): Pixel => new Pixel()); + + /** @var ObjectProphecy> $channelRepository */ + $channelRepository = $this->prophesize(ChannelRepositoryInterface::class); + $channelRepository->findAll()->willReturn([$this->webChannel, $this->mobileChannel]); + $channelRepository->findOneBy(['code' => 'WEB'])->willReturn($this->webChannel); + $channelRepository->findOneBy(['code' => 'MOBILE'])->willReturn($this->mobileChannel); + + $this->factory = new PixelExampleFactory($pixelFactory->reveal(), $channelRepository->reveal()); + } + + /** + * @test + */ + public function it_creates_a_pixel_from_options(): void + { + $pixel = $this->factory->create([ + 'pixel_id' => 123456789, + 'access_token' => 'access_token', + 'enabled' => false, + 'channels' => ['WEB'], + ]); + + self::assertSame('123456789', $pixel->getPixelId()); + self::assertSame('access_token', $pixel->getAccessToken()); + self::assertFalse($pixel->isEnabled()); + self::assertTrue($pixel->hasChannel($this->webChannel)); + self::assertFalse($pixel->hasChannel($this->mobileChannel)); + } + + /** + * @test + */ + public function it_uses_all_channels_and_generates_an_access_token_by_default(): void + { + $pixel = $this->factory->create(['pixel_id' => '987654321']); + + self::assertSame('987654321', $pixel->getPixelId()); + self::assertNotNull($pixel->getAccessToken()); + self::assertNotSame('', $pixel->getAccessToken()); + self::assertTrue($pixel->isEnabled()); + self::assertTrue($pixel->hasChannel($this->webChannel)); + self::assertTrue($pixel->hasChannel($this->mobileChannel)); + } + + /** + * @test + */ + public function it_requires_a_numeric_pixel_id(): void + { + $this->expectException(InvalidOptionsException::class); + + $this->factory->create(['pixel_id' => 'not numeric']); + } +} diff --git a/tests/Unit/Fixture/PixelFixtureTest.php b/tests/Unit/Fixture/PixelFixtureTest.php new file mode 100644 index 0000000..65806e1 --- /dev/null +++ b/tests/Unit/Fixture/PixelFixtureTest.php @@ -0,0 +1,70 @@ +prophesize(ObjectManager::class)->reveal(), + $this->prophesize(ExampleFactoryInterface::class)->reveal(), + ); + + self::assertSame('setono_facebook_pixel', $fixture->getName()); + } + + /** + * @test + */ + public function it_creates_and_persists_the_configured_pixels(): void + { + $pixel = new Pixel(); + + $objectManager = $this->prophesize(ObjectManager::class); + $objectManager->persist($pixel)->shouldBeCalledOnce(); + $objectManager->flush()->shouldBeCalled(); + $objectManager->clear()->shouldBeCalled(); + + $exampleFactory = $this->prophesize(ExampleFactoryInterface::class); + $exampleFactory->create([ + 'pixel_id' => '123456789', + 'access_token' => 'access_token', + 'enabled' => true, + 'channels' => ['WEB'], + ])->willReturn($pixel)->shouldBeCalledOnce(); + + $fixture = new PixelFixture($objectManager->reveal(), $exampleFactory->reveal()); + + $fixture->load($this->processOptions($fixture, [ + 'custom' => [ + ['pixel_id' => '123456789', 'access_token' => 'access_token', 'channels' => ['WEB']], + ], + ])); + } + + /** + * @param array $options + * + * @return array + */ + private function processOptions(PixelFixture $fixture, array $options): array + { + return (new Processor())->process($fixture->getConfigTreeBuilder()->buildTree(), [$options]); + } +} diff --git a/tests/Unit/Form/Type/PixelTypeTest.php b/tests/Unit/Form/Type/PixelTypeTest.php new file mode 100644 index 0000000..71e4713 --- /dev/null +++ b/tests/Unit/Form/Type/PixelTypeTest.php @@ -0,0 +1,116 @@ +channel = new Channel(); + $this->channel->setCode('WEB'); + $this->channel->setName('Web'); + + parent::setUp(); + } + + /** + * @return list + */ + protected function getExtensions(): array + { + /** @var ObjectProphecy> $channelRepository */ + $channelRepository = $this->prophesize(RepositoryInterface::class); + $channelRepository->findAll()->willReturn([$this->channel]); + + return [ + new PreloadedExtension([ + new PixelType(Pixel::class, ['setono_sylius_facebook']), + new ChannelChoiceType($channelRepository->reveal()), + ], []), + ]; + } + + /** + * @test + */ + public function it_builds_the_pixel_form(): void + { + $view = $this->factory->create(PixelType::class)->createView(); + + self::assertSame('setono_sylius_facebook_pixel', self::vars($view)['name']); + + $pixelId = self::vars($view->children['pixelId']); + self::assertSame('setono_sylius_facebook.form.pixel.pixel_id', $pixelId['label']); + self::assertSame('setono_sylius_facebook.form.pixel.pixel_id_help', $pixelId['help']); + self::assertSame(['placeholder' => 'setono_sylius_facebook.form.pixel.pixel_id_placeholder'], $pixelId['attr']); + self::assertTrue($pixelId['required']); + + $accessToken = self::vars($view->children['accessToken']); + self::assertSame('setono_sylius_facebook.form.pixel.access_token', $accessToken['label']); + self::assertSame(['rows' => 3, 'placeholder' => 'setono_sylius_facebook.form.pixel.access_token_placeholder'], $accessToken['attr']); + self::assertFalse($accessToken['required']); + + $enabled = self::vars($view->children['enabled']); + self::assertSame('sylius.ui.enabled', $enabled['label']); + self::assertFalse($enabled['required']); + + $channels = self::vars($view->children['channels']); + self::assertSame('sylius.ui.channels', $channels['label']); + self::assertTrue($channels['multiple']); + self::assertTrue($channels['expanded']); + self::assertCount(1, $view->children['channels']->children); + } + + /** + * @return array + */ + private static function vars(FormView $view): array + { + $vars = $view->vars; + self::assertIsArray($vars); + + return $vars; + } + + /** + * @test + */ + public function it_maps_submitted_data_to_a_pixel(): void + { + $form = $this->factory->create(PixelType::class); + $form->submit([ + 'pixelId' => '123456789', + 'accessToken' => 'access_token', + 'enabled' => '1', + 'channels' => ['WEB'], + ]); + + self::assertTrue($form->isSynchronized()); + + $pixel = $form->getData(); + self::assertInstanceOf(Pixel::class, $pixel); + self::assertSame('123456789', $pixel->getPixelId()); + self::assertSame('access_token', $pixel->getAccessToken()); + self::assertTrue($pixel->isEnabled()); + self::assertTrue($pixel->hasChannel($this->channel)); + } +} diff --git a/tests/Unit/Menu/AdminMenuListenerTest.php b/tests/Unit/Menu/AdminMenuListenerTest.php new file mode 100644 index 0000000..dd7ebbe --- /dev/null +++ b/tests/Unit/Menu/AdminMenuListenerTest.php @@ -0,0 +1,83 @@ +createMenu(); + $menu->addChild('catalog'); + $menu->addChild('marketing'); + + (new AdminMenuListener())->addAdminMenuItems(new MenuBuilderEvent($factory, $menu)); + + $marketing = $menu->getChild('marketing'); + self::assertNotNull($marketing); + + $this->assertHasPixelIndexItem($marketing); + + $catalog = $menu->getChild('catalog'); + self::assertNotNull($catalog); + self::assertNull($catalog->getChild('facebook_tracking')); + } + + /** + * @test + */ + public function it_adds_the_pixel_index_to_the_first_menu_when_there_is_no_marketing_menu(): void + { + [$factory, $menu] = $this->createMenu(); + $menu->addChild('catalog'); + $menu->addChild('sales'); + + (new AdminMenuListener())->addAdminMenuItems(new MenuBuilderEvent($factory, $menu)); + + $catalog = $menu->getChild('catalog'); + self::assertNotNull($catalog); + + $this->assertHasPixelIndexItem($catalog); + } + + private function assertHasPixelIndexItem(ItemInterface $parent): void + { + $item = $parent->getChild('facebook_tracking'); + self::assertNotNull($item); + self::assertSame('/admin/facebook/pixels/', $item->getUri()); + self::assertSame('setono_sylius_facebook.ui.facebook', $item->getLabel()); + self::assertSame('facebook', $item->getLabelAttribute('icon')); + } + + /** + * @return array{MenuFactory, ItemInterface} + */ + private function createMenu(): array + { + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator + ->generate('setono_sylius_facebook_admin_pixel_index', [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->willReturn('/admin/facebook/pixels/') + ; + + $factory = new MenuFactory(); + $factory->addExtension(new RoutingExtension($urlGenerator->reveal())); + + return [$factory, $factory->createItem('root')]; + } +} diff --git a/tests/Unit/Model/PixelTest.php b/tests/Unit/Model/PixelTest.php new file mode 100644 index 0000000..86c2a35 --- /dev/null +++ b/tests/Unit/Model/PixelTest.php @@ -0,0 +1,86 @@ +getId()); + self::assertNull($pixel->getPixelId()); + self::assertNull($pixel->getAccessToken()); + self::assertTrue($pixel->isEnabled()); + self::assertCount(0, $pixel->getChannels()); + } + + /** + * @test + */ + public function it_casts_to_string_using_the_pixel_id(): void + { + $pixel = new Pixel(); + self::assertSame('', (string) $pixel); + + $pixel->setPixelId('123456789'); + self::assertSame('123456789', (string) $pixel); + } + + /** + * @test + */ + public function it_adds_a_channel_only_once(): void + { + $channel = new Channel(); + + $pixel = new Pixel(); + $pixel->addChannel($channel); + $pixel->addChannel($channel); + + self::assertTrue($pixel->hasChannel($channel)); + self::assertCount(1, $pixel->getChannels()); + } + + /** + * @test + */ + public function it_removes_a_channel(): void + { + $channel = new Channel(); + $otherChannel = new Channel(); + + $pixel = new Pixel(); + $pixel->addChannel($channel); + $pixel->addChannel($otherChannel); + + $pixel->removeChannel($channel); + $pixel->removeChannel($channel); + + self::assertFalse($pixel->hasChannel($channel)); + self::assertTrue($pixel->hasChannel($otherChannel)); + self::assertCount(1, $pixel->getChannels()); + } + + /** + * @test + */ + public function it_has_an_access_token(): void + { + $pixel = new Pixel(); + $pixel->setAccessToken('access_token'); + self::assertSame('access_token', $pixel->getAccessToken()); + + $pixel->setAccessToken(null); + self::assertNull($pixel->getAccessToken()); + } +} diff --git a/tests/Unit/Provider/DoctrineBasedPixelProviderTest.php b/tests/Unit/Provider/DoctrineBasedPixelProviderTest.php new file mode 100644 index 0000000..4b97ff6 --- /dev/null +++ b/tests/Unit/Provider/DoctrineBasedPixelProviderTest.php @@ -0,0 +1,65 @@ +setPixelId('123'); + $pixelWithAccessToken->setAccessToken('access_token'); + + $pixelWithoutAccessToken = new Pixel(); + $pixelWithoutAccessToken->setPixelId('456'); + + $channelContext = $this->prophesize(ChannelContextInterface::class); + $channelContext->getChannel()->willReturn($channel); + + $pixelRepository = $this->prophesize(PixelRepositoryInterface::class); + $pixelRepository->findEnabledByChannel($channel)->willReturn([$pixelWithAccessToken, $pixelWithoutAccessToken]); + + $provider = new DoctrineBasedPixelProvider($pixelRepository->reveal(), $channelContext->reveal()); + + self::assertEquals([ + new ConversionsApiPixel('123', 'access_token'), + new ConversionsApiPixel('456'), + ], $provider->getPixels()); + } + + /** + * @test + */ + public function it_provides_no_pixels_when_none_are_enabled(): void + { + $channel = new Channel(); + + $channelContext = $this->prophesize(ChannelContextInterface::class); + $channelContext->getChannel()->willReturn($channel); + + $pixelRepository = $this->prophesize(PixelRepositoryInterface::class); + $pixelRepository->findEnabledByChannel($channel)->willReturn([]); + + $provider = new DoctrineBasedPixelProvider($pixelRepository->reveal(), $channelContext->reveal()); + + self::assertSame([], $provider->getPixels()); + } +} From 95d44580329a98eb215ca2b4f40249cfe16c1bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:28:04 +0200 Subject: [PATCH 2/9] Run unit and functional tests in CI The unit tests job runs the unit suite, the integration tests job runs the functional suite after creating the schema, and the coverage and mutation jobs start MySQL and create the schema so the functional tests are included. Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- .github/workflows/build.yaml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index cb0d16d..6d20687 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -192,8 +192,8 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" - - name: "Run phpunit" - run: "composer phpunit" + - name: "Run unit tests" + run: "vendor/bin/phpunit --testsuite unit" integration-tests: name: "Integration tests (PHP${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }} | SF${{ matrix.symfony }})" @@ -247,6 +247,9 @@ jobs: - name: "Validate Doctrine mapping" run: "(cd tests/Application && bin/console doctrine:schema:validate -vvv)" # The verbose flag will show 'missing' SQL statements, if any + + - name: "Run functional tests" + run: "vendor/bin/phpunit --testsuite functional" mutation-tests: name: "Mutation tests" @@ -277,6 +280,15 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" + - name: "Start MySQL" + run: "sudo /etc/init.d/mysql start" + + - name: "Create database" + run: "(cd tests/Application && bin/console doctrine:database:create)" + + - name: "Create database schema" + run: "(cd tests/Application && bin/console doctrine:schema:create)" + - name: "Run infection" run: "vendor/bin/infection" env: @@ -311,6 +323,15 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" + - name: "Start MySQL" + run: "sudo /etc/init.d/mysql start" + + - name: "Create database" + run: "(cd tests/Application && bin/console doctrine:database:create)" + + - name: "Create database schema" + run: "(cd tests/Application && bin/console doctrine:schema:create)" + - name: "Collect code coverage with pcov and phpunit/phpunit" run: "vendor/bin/phpunit --coverage-clover=.build/logs/clover.xml" From 6638f0a1a03d8c93754d468c1ba6349a7b86340f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:37:06 +0200 Subject: [PATCH 3/9] Cover the bundle class with a unit test In CI the test container is already compiled by the schema creation steps, so booting the kernel in the functional tests never executed SetonoSyliusFacebookPlugin::build()/getSupportedDrivers() under coverage and Infection reported their mutants as uncovered. Test the bundle class directly instead of relying on the container cache state. Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- tests/Unit/SetonoSyliusFacebookPluginTest.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/Unit/SetonoSyliusFacebookPluginTest.php diff --git a/tests/Unit/SetonoSyliusFacebookPluginTest.php b/tests/Unit/SetonoSyliusFacebookPluginTest.php new file mode 100644 index 0000000..cc61363 --- /dev/null +++ b/tests/Unit/SetonoSyliusFacebookPluginTest.php @@ -0,0 +1,50 @@ +getSupportedDrivers()); + } + + /** + * @test + */ + public function it_has_a_container_extension(): void + { + self::assertInstanceOf(SetonoSyliusFacebookExtension::class, (new SetonoSyliusFacebookPlugin())->getContainerExtension()); + } + + /** + * @test + */ + public function it_registers_the_doctrine_mapping_and_the_pixel_provider_compiler_passes(): void + { + $container = new ContainerBuilder(); + + (new SetonoSyliusFacebookPlugin())->build($container); + + $passes = array_map( + static fn (object $pass): string => $pass::class, + $container->getCompilerPassConfig()->getBeforeOptimizationPasses(), + ); + + self::assertContains(DoctrineOrmMappingsPass::class, $passes); + self::assertContains(OverrideDefaultPixelProviderPass::class, $passes); + } +} From 9bfbd69b9e12739c9cdff1c25da2ed1d0ad857f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:37:06 +0200 Subject: [PATCH 4/9] Exclude payum/payum-bundle 2.7.0 from the test application payum/payum-bundle 2.7.0 replaced its XML routing files with YAML, which breaks the @PayumBundle/Resources/config/routing/*.xml imports in Sylius 1.14's shop routing; 2.7.1 restored the XML files as a backwards compatibility layer. The "lowest" dependency resolution in CI landed on exactly 2.7.0, so the functional tests could not build the router. Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 317df39..b37b891 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "jms/serializer-bundle": "^4.2", "lexik/jwt-authentication-bundle": "^2.17", "nyholm/psr7": "^1.8", + "payum/payum-bundle": "^2.6 !=2.7.0", "setono/sylius-plugin-pack": "~1.14.1", "setono/tag-bag": "^2.3", "setono/tag-bag-bundle": "^3.0", From 40d9736cc0a27ae2ea91be0f54fd27055baadabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:41:28 +0200 Subject: [PATCH 5/9] Build the test application frontend in CI instead of disabling asset checks The integration, coverage and mutation jobs now install Node (from tests/Application/.nvmrc) and run yarn install && yarn build before the functional tests, so the test environment no longer needs to disable Webpack Encore's strict mode or drop the asset manifests. Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- .github/workflows/build.yaml | 24 +++++++++++++++++++ .../config/packages/test/assets.yaml | 8 ------- .../config/packages/test/webpack_encore.yaml | 3 --- 3 files changed, 24 insertions(+), 11 deletions(-) delete mode 100644 tests/Application/config/packages/test/assets.yaml delete mode 100644 tests/Application/config/packages/test/webpack_encore.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6d20687..5bfe2db 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -236,6 +236,14 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" + - name: "Setup Node" + uses: "actions/setup-node@v7" + with: + node-version-file: "tests/Application/.nvmrc" + + - name: "Build frontend assets" + run: "(cd tests/Application && yarn install && yarn build)" + - name: "Lint container" run: "(cd tests/Application && bin/console lint:container)" @@ -280,6 +288,14 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" + - name: "Setup Node" + uses: "actions/setup-node@v7" + with: + node-version-file: "tests/Application/.nvmrc" + + - name: "Build frontend assets" + run: "(cd tests/Application && yarn install && yarn build)" + - name: "Start MySQL" run: "sudo /etc/init.d/mysql start" @@ -323,6 +339,14 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" + - name: "Setup Node" + uses: "actions/setup-node@v7" + with: + node-version-file: "tests/Application/.nvmrc" + + - name: "Build frontend assets" + run: "(cd tests/Application && yarn install && yarn build)" + - name: "Start MySQL" run: "sudo /etc/init.d/mysql start" diff --git a/tests/Application/config/packages/test/assets.yaml b/tests/Application/config/packages/test/assets.yaml deleted file mode 100644 index cc35461..0000000 --- a/tests/Application/config/packages/test/assets.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# The frontend is not built when running the test suite (e.g. in CI), so do not require the Webpack manifests -framework: - assets: - packages: - shop: - json_manifest_path: ~ - admin: - json_manifest_path: ~ diff --git a/tests/Application/config/packages/test/webpack_encore.yaml b/tests/Application/config/packages/test/webpack_encore.yaml deleted file mode 100644 index 049e0be..0000000 --- a/tests/Application/config/packages/test/webpack_encore.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# The frontend is not built when running the test suite (e.g. in CI), so do not fail on missing entrypoints -webpack_encore: - strict_mode: false From 7c67b7f76027388392117efa85767153a910c8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:41:28 +0200 Subject: [PATCH 6/9] Lower the mutation score thresholds instead of ignoring mutators Without ignores the suite kills 165 of 186 mutants (MSI 88.7%); the remaining 21 are defensive (string) casts of nullable getters, type-narrowing asserts after OptionsResolver/DQL validation and the trait method visibility. Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- infection.json.dist | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/infection.json.dist b/infection.json.dist index 29d9201..49242f4 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -11,26 +11,6 @@ "badge": "3.x" } }, - "minMsi": 100.00, - "minCoveredMsi": 100.00, - "mutators": { - "@default": true, - "CastString": { - "ignore": [ - "Setono\\SyliusFacebookPlugin\\Event\\*", - "Setono\\SyliusFacebookPlugin\\Provider\\DoctrineBasedPixelProvider::getPixels" - ] - }, - "MethodCallRemoval": { - "ignore": [ - "Setono\\SyliusFacebookPlugin\\Fixture\\Factory\\PixelExampleFactory::create", - "Setono\\SyliusFacebookPlugin\\Repository\\PixelRepository::findEnabledByChannel" - ] - }, - "ProtectedVisibility": { - "ignore": [ - "Setono\\SyliusFacebookPlugin\\Event\\FormatAmountTrait::formatAmount" - ] - } - } + "minMsi": 88, + "minCoveredMsi": 88 } From 6dfecdf630269caa4e47a06e20008ff7c541f3ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:45:55 +0200 Subject: [PATCH 7/9] Set the mutation score thresholds to the actual single-threaded score Running Infection single-threaded (as CI does) kills 162 of 186 mutants (87.1%); the earlier 88.7% measured locally with four threads counted three equivalent mutants as killed because parallel functional tests collided on the same unique channel codes in the shared test database. Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- infection.json.dist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infection.json.dist b/infection.json.dist index 49242f4..36313a0 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -11,6 +11,6 @@ "badge": "3.x" } }, - "minMsi": 88, - "minCoveredMsi": 88 + "minMsi": 87, + "minCoveredMsi": 87 } From b0d2b9a3bc51c0ba2acaad8f0a726a6716f6bf55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:56:55 +0200 Subject: [PATCH 8/9] Import the Payum routes format-agnostically in the test application Replaces the payum/payum-bundle version constraint: instead of going through Sylius' XML-only payum.yml, the test application imports the same three Payum routing files with a {xml,yaml} glob, so it works with payum-bundle 2.7.0 (YAML only) as well as with < 2.7 (XML only) and >= 2.7.1 (both). Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- composer.json | 1 - tests/Application/config/routes/sylius_shop.yaml | 12 ++++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b37b891..317df39 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,6 @@ "jms/serializer-bundle": "^4.2", "lexik/jwt-authentication-bundle": "^2.17", "nyholm/psr7": "^1.8", - "payum/payum-bundle": "^2.6 !=2.7.0", "setono/sylius-plugin-pack": "~1.14.1", "setono/tag-bag": "^2.3", "setono/tag-bag-bundle": "^3.0", diff --git a/tests/Application/config/routes/sylius_shop.yaml b/tests/Application/config/routes/sylius_shop.yaml index 22e7a4a..1008063 100644 --- a/tests/Application/config/routes/sylius_shop.yaml +++ b/tests/Application/config/routes/sylius_shop.yaml @@ -4,8 +4,16 @@ sylius_shop: requirements: _locale: ^[A-Za-z]{2,4}(_([A-Za-z]{4}|[0-9]{3}))?(_([A-Za-z]{2}|[0-9]{3}))?$ -sylius_shop_payum: - resource: "@SyliusShopBundle/Resources/config/routing/payum.yml" +# Same routes as /Resources/config/routing/payum.yml, but importing both the XML and the YAML variants: +# payum/payum-bundle 2.7.0 only ships YAML routing files, while Sylius (and payum-bundle < 2.7 / >= 2.7.1) uses the XML ones +sylius_shop_payum_authorize: + resource: "@PayumBundle/Resources/config/routing/authorize.{xml,yaml}" + +sylius_shop_payum_capture: + resource: "@PayumBundle/Resources/config/routing/capture.{xml,yaml}" + +sylius_shop_payum_notify: + resource: "@PayumBundle/Resources/config/routing/notify.{xml,yaml}" sylius_shop_default_locale: path: / From a777f5ee2203eaf0186a99fdce4bcf6e8c65e0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 11:59:59 +0200 Subject: [PATCH 9/9] Revert "Import the Payum routes format-agnostically in the test application" The require-dev constraint "payum/payum-bundle": "^2.6 !=2.7.0" is the simpler way to keep payum-bundle 2.7.0 (YAML-only routing, incompatible with Sylius 1.14's imports) out of the test application, and it keeps the test application's shop routing identical to Sylius'. This reverts commit b0d2b9a3bc51c0ba2acaad8f0a726a6716f6bf55. Claude-Session: https://claude.ai/code/session_013jh2QjzkxAJABPDYB1UiPS --- composer.json | 1 + tests/Application/config/routes/sylius_shop.yaml | 12 ++---------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 317df39..b37b891 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "jms/serializer-bundle": "^4.2", "lexik/jwt-authentication-bundle": "^2.17", "nyholm/psr7": "^1.8", + "payum/payum-bundle": "^2.6 !=2.7.0", "setono/sylius-plugin-pack": "~1.14.1", "setono/tag-bag": "^2.3", "setono/tag-bag-bundle": "^3.0", diff --git a/tests/Application/config/routes/sylius_shop.yaml b/tests/Application/config/routes/sylius_shop.yaml index 1008063..22e7a4a 100644 --- a/tests/Application/config/routes/sylius_shop.yaml +++ b/tests/Application/config/routes/sylius_shop.yaml @@ -4,16 +4,8 @@ sylius_shop: requirements: _locale: ^[A-Za-z]{2,4}(_([A-Za-z]{4}|[0-9]{3}))?(_([A-Za-z]{2}|[0-9]{3}))?$ -# Same routes as /Resources/config/routing/payum.yml, but importing both the XML and the YAML variants: -# payum/payum-bundle 2.7.0 only ships YAML routing files, while Sylius (and payum-bundle < 2.7 / >= 2.7.1) uses the XML ones -sylius_shop_payum_authorize: - resource: "@PayumBundle/Resources/config/routing/authorize.{xml,yaml}" - -sylius_shop_payum_capture: - resource: "@PayumBundle/Resources/config/routing/capture.{xml,yaml}" - -sylius_shop_payum_notify: - resource: "@PayumBundle/Resources/config/routing/notify.{xml,yaml}" +sylius_shop_payum: + resource: "@SyliusShopBundle/Resources/config/routing/payum.yml" sylius_shop_default_locale: path: /