Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
/rector.php export-ignore
/README.md export-ignore
/UPGRADE.md export-ignore
/codecov.yml export-ignore
/composer-dependency-analyser.php export-ignore
16 changes: 16 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Coverage is a gate, not a report: the bundle sat at 46% of lines while most of its bugs lived in the untested
# half, so a drop should fail the pull request rather than show up as a number nobody reads
coverage:
status:
project:
default:
target: 95%
# Small unavoidable dips (a new @codeCoverageIgnore, a refactor) should not block a merge
threshold: 1%
patch:
default:
target: 90%

comment:
layout: "condensed_header, diff, files"
require_changes: true
66 changes: 66 additions & 0 deletions tests/Double/Doubles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Tests\Double;

use Setono\MetaConversionsApi\Pixel\Pixel;
use Setono\MetaConversionsApi\ValueObject\Fbc;
use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface;
use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface;
use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface;

final class Doubles
{
public static function consentChecker(bool $granted): ConsentCheckerInterface
{
return new class($granted) implements ConsentCheckerInterface {
public function __construct(private readonly bool $granted)
{
}

public function isGranted(): bool
{
return $this->granted;
}
};
}

/**
* @param list<Pixel> $pixels
*/
public static function pixelProvider(array $pixels): PixelProviderInterface
{
return new class($pixels) implements PixelProviderInterface {
/**
* @param list<Pixel> $pixels
*/
public function __construct(private readonly array $pixels)
{
}

public function getPixels(): array
{
return $this->pixels;
}
};
}

public static function fbcContext(?Fbc $fbc): FbcContextInterface
{
return new class($fbc) implements FbcContextInterface {
public function __construct(private readonly ?Fbc $fbc)
{
}

public function getFbc(): ?Fbc
{
return $this->fbc;
}
};
}

private function __construct()
{
}
}
34 changes: 34 additions & 0 deletions tests/Double/RecordingConversionsApiClientFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Tests\Double;

use Setono\MetaConversionsApi\Client\ClientInterface;
use Setono\MetaConversionsApi\Event\Event;

/**
* Records every event handed to the SDK client, so an end to end test can inspect what would have been sent
*
* Built through a factory because a container definition cannot hold a live object
*/
final class RecordingConversionsApiClientFactory
{
/** @var list<Event> */
public static array $events = [];

public static function reset(): void
{
self::$events = [];
}

public static function create(): ClientInterface
{
return new class() implements ClientInterface {
public function sendEvent(Event $event): void
{
RecordingConversionsApiClientFactory::$events[] = $event;
}
};
}
}
174 changes: 174 additions & 0 deletions tests/Integration/PipelineTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Tests\Integration;

use Nyholm\BundleTest\TestKernel;
use PHPUnit\Framework\Attributes\Test;
use Setono\BotDetectionBundle\SetonoBotDetectionBundle;
use Setono\MetaConversionsApi\Client\ClientInterface;
use Setono\MetaConversionsApi\Event\Event;
use Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised;
use Setono\MetaConversionsApiBundle\SetonoMetaConversionsApiBundle;
use Setono\MetaConversionsApiBundle\Tests\Double\RecordingConversionsApiClientFactory;
use Setono\TagBag\TagBagInterface;
use Setono\TagBagBundle\SetonoTagBagBundle;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\KernelInterface;

/**
* Dispatches a real event through a booted kernel and asserts what comes out the other end
*
* The unit tests cover each listener on its own. This one covers the wiring between them, which is where a
* regression like a missing service argument or a bus that no longer exists actually shows up
*/
final class PipelineTest extends KernelTestCase
{
protected static function getKernelClass(): string
{
return TestKernel::class;
}

/**
* @param array<mixed> $options
*/
protected static function createKernel(array $options = []): KernelInterface
{
/** @var TestKernel $kernel */
$kernel = parent::createKernel($options);
$kernel->addTestBundle(SetonoMetaConversionsApiBundle::class);
$kernel->addTestBundle(SetonoBotDetectionBundle::class);
$kernel->addTestBundle(SetonoTagBagBundle::class);
$kernel->handleOptions($options);

return $kernel;
}

protected function setUp(): void
{
RecordingConversionsApiClientFactory::reset();
}

#[Test]
public function it_sends_an_enriched_event_and_renders_the_tags(): void
{
self::boot();
self::pushRequest('Mozilla/5.0 (Macintosh) Chrome/140.0');

$container = self::getContainer();

$dispatcher = $container->get('test.event_dispatcher');
self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher);

$metaEvent = new Event(Event::EVENT_VIEW_CONTENT);
$metaEvent->customData->contentName = 'Blue Jeans';

// An application listener enriching at the documented priority
$dispatcher->addListener(
ConversionsApiEventRaised::class,
static function (ConversionsApiEventRaised $event): void {
$event->event->userData->email[] = 'customer@example.com';
},
ConversionsApiEventRaised::PRIORITY_ENRICH,
);

$dispatcher->dispatch(new ConversionsApiEventRaised($metaEvent), ConversionsApiEventRaised::class);

// Server side: the command was dispatched, handled, and reached the client
self::assertCount(1, RecordingConversionsApiClientFactory::$events);
$sent = RecordingConversionsApiClientFactory::$events[0]->getPayload();

self::assertSame('ViewContent', $sent['event_name']);
self::assertSame('https://example.com/jeans', $sent['event_source_url']);
self::assertSame($metaEvent->eventId, $sent['event_id']);

$userData = $sent['user_data'];
self::assertIsArray($userData);
self::assertSame('Mozilla/5.0 (Macintosh) Chrome/140.0', $userData['client_user_agent']);
self::assertArrayHasKey('fbp', $userData);
// The application's email is hashed, never sent raw
self::assertSame([hash('sha256', 'customer@example.com')], $userData['em']);

// Client side: the pixel, the init and the track call are in the tag bag
$tagBag = $container->get('test.tag_bag');
self::assertInstanceOf(TagBagInterface::class, $tagBag);

// The library tag itself hangs off kernel.request, which this test does not fire, so it is covered by
// AddLibraryToTagBagSubscriberTest instead
$rendered = $tagBag->renderAll();
self::assertStringContainsString("fbq('init', '1234'", $rendered);
self::assertStringContainsString("fbq('track', 'ViewContent'", $rendered);
// The same event id on both sides is what makes Meta deduplicate the pair
self::assertStringContainsString(sprintf("eventID: '%s'", $metaEvent->eventId), $rendered);
}

#[Test]
public function it_sends_nothing_for_a_bot(): void
{
self::boot();
self::pushRequest('Googlebot/2.1 (+http://www.google.com/bot.html)');

$container = self::getContainer();

$dispatcher = $container->get('test.event_dispatcher');
self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher);

$enriched = false;
$dispatcher->addListener(
ConversionsApiEventRaised::class,
static function () use (&$enriched): void {
$enriched = true;
},
ConversionsApiEventRaised::PRIORITY_ENRICH,
);

$dispatcher->dispatch(new ConversionsApiEventRaised(new Event(Event::EVENT_VIEW_CONTENT)), ConversionsApiEventRaised::class);

self::assertSame([], RecordingConversionsApiClientFactory::$events);
// ... and the application never spent anything enriching it
self::assertFalse($enriched);

$tagBag = $container->get('test.tag_bag');
self::assertInstanceOf(TagBagInterface::class, $tagBag);
self::assertStringNotContainsString('fbq(', $tagBag->renderAll());
}

private static function boot(): void
{
self::bootKernel(['config' => function (TestKernel $kernel) {
$kernel->addTestConfig(static function (ContainerBuilder $container) {
$container->loadFromExtension('setono_tag_bag', [
'renderer' => ['twig' => false],
]);
$container->loadFromExtension('setono_meta_conversions_api', [
'client_side' => true,
'pixels' => [
['id' => '1234', 'access_token' => 's3cr3t'],
],
]);

$container->register(ClientInterface::class, ClientInterface::class)
->setFactory([RecordingConversionsApiClientFactory::class, 'create']);

$container->setAlias('test.event_dispatcher', 'event_dispatcher')->setPublic(true);
$container->setAlias('test.tag_bag', 'setono_tag_bag.tag_bag')->setPublic(true);
$container->setAlias('test.request_stack', 'request_stack')->setPublic(true);
});
}]);
}

private static function pushRequest(string $userAgent): void
{
$request = Request::create('https://example.com/jeans');
$request->headers->set('User-Agent', $userAgent);

$requestStack = self::getContainer()->get('test.request_stack');
self::assertInstanceOf(RequestStack::class, $requestStack);
$requestStack->push($request);
}
}
73 changes: 73 additions & 0 deletions tests/Unit/ConsentChecker/ConsentCheckerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Tests\Unit\ConsentChecker;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Setono\Consent\ConsentCheckerInterface as ThirdPartyConsentCheckerInterface;
use Setono\Consent\DefaultConsents;
use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentChecker;

#[CoversClass(ConsentChecker::class)]
final class ConsentCheckerTest extends TestCase
{
#[Test]
public function it_grants_when_consent_handling_is_disabled(): void
{
$checker = new ConsentChecker(false, DefaultConsents::CONSENT_MARKETING, self::thirdParty(false));

self::assertTrue($checker->isGranted());
}

#[Test]
public function it_grants_when_the_consent_bundle_is_not_installed(): void
{
$checker = new ConsentChecker(true, DefaultConsents::CONSENT_MARKETING, null);

self::assertTrue($checker->isGranted());
}

#[Test]
public function it_delegates_to_the_consent_bundle(): void
{
self::assertTrue((new ConsentChecker(true, DefaultConsents::CONSENT_MARKETING, self::thirdParty(true)))->isGranted());
self::assertFalse((new ConsentChecker(true, DefaultConsents::CONSENT_MARKETING, self::thirdParty(false)))->isGranted());
}

#[Test]
public function it_asks_for_the_configured_category(): void
{
$thirdParty = new class() implements ThirdPartyConsentCheckerInterface {
/** @var list<string> */
public array $asked = [];

public function isGranted(string $consent): bool
{
$this->asked[] = $consent;

return true;
}
};

(new ConsentChecker(true, DefaultConsents::CONSENT_STATISTICAL, $thirdParty))->isGranted();

self::assertSame([DefaultConsents::CONSENT_STATISTICAL], $thirdParty->asked);
}

private static function thirdParty(bool $granted): ThirdPartyConsentCheckerInterface
{
return new class($granted) implements ThirdPartyConsentCheckerInterface {
public function __construct(private readonly bool $granted)
{
}

public function isGranted(string $consent): bool
{
return $this->granted;
}
};
}
}
Loading
Loading