diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index cb0d16d..5bfe2db 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 }})" @@ -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)" @@ -247,6 +255,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 +288,23 @@ 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" + + - 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 +339,23 @@ 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" + + - 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" diff --git a/composer.json b/composer.json index 7095c6e..b37b891 100644 --- a/composer.json +++ b/composer.json @@ -44,15 +44,19 @@ "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", "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", "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..36313a0 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -11,6 +11,6 @@ "badge": "3.x" } }, - "minMsi": 100.00, - "minCoveredMsi": 100.00 + "minMsi": 87, + "minCoveredMsi": 87 } 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/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()); + } +} 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); + } +}