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