Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/Context/Fbc/CookieBasedFbcContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Setono\MetaConversionsApi\ValueObject\Fbc;
use Setono\MetaConversionsApiBundle\Cookie\Cookies;
use Symfony\Component\HttpFoundation\RequestStack;

final class CookieBasedFbcContext implements FbcContextInterface
Expand All @@ -27,7 +28,7 @@ public function getFbc(): ?Fbc
return null;
}

$cookie = $request->cookies->get('_fbc');
$cookie = $request->cookies->get(Cookies::FBC);
if (!is_string($cookie) || '' === $cookie) {
return null;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Context/Fbp/CookieBasedFbpContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Setono\MetaConversionsApi\ValueObject\Fbp;
use Setono\MetaConversionsApiBundle\Cookie\Cookies;
use Symfony\Component\HttpFoundation\RequestStack;

final class CookieBasedFbpContext implements FbpContextInterface
Expand All @@ -28,7 +29,7 @@ public function getFbp(): Fbp
return $this->decorated->getFbp();
}

$cookie = $request->cookies->get('_fbp');
$cookie = $request->cookies->get(Cookies::FBP);
if (!is_string($cookie) || '' === $cookie) {
return $this->decorated->getFbp();
}
Expand Down
26 changes: 26 additions & 0 deletions src/Cookie/Cookies.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Cookie;

/**
* The cookies Meta uses, shared by the contexts that read them and the subscribers that write them
*
* See https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
*/
final class Cookies
{
public const FBP = '_fbp';

public const FBC = '_fbc';

/**
* Meta keeps these for 90 days
*/
public const LIFETIME = '+90 days';

private function __construct()
{
}
}
16 changes: 13 additions & 3 deletions src/EventSubscriber/StoreFbcSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface;
use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface;
use Setono\MetaConversionsApiBundle\Cookie\Cookies;
use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
Expand All @@ -21,6 +23,7 @@ final class StoreFbcSubscriber implements EventSubscriberInterface
public function __construct(
private readonly FbcContextInterface $fbcContext,
private readonly ConsentCheckerInterface $consentChecker,
private readonly PixelProviderInterface $pixelProvider,
) {
}

Expand All @@ -42,6 +45,13 @@ public function store(ResponseEvent $event): void
return;
}

$response = $event->getResponse();

// There is no point in remembering a click we have nowhere to send events to
if ([] === $this->pixelProvider->getPixels()) {
return;
}

if (!$this->consentChecker->isGranted()) {
return;
}
Expand All @@ -51,10 +61,10 @@ public function store(ResponseEvent $event): void
return;
}

$event->getResponse()->headers->setCookie(Cookie::create(
'_fbc',
$response->headers->setCookie(Cookie::create(
Cookies::FBC,
$fbc->value(),
new \DateTimeImmutable('+90 days'),
new \DateTimeImmutable(Cookies::LIFETIME),
)->withHttpOnly(false)); // we need this to allow the js library to also use the cookie value
}
}
30 changes: 17 additions & 13 deletions src/EventSubscriber/StoreFbpSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Setono\MetaConversionsApi\ValueObject\Fbp;
use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface;
use Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface;
use Setono\MetaConversionsApiBundle\Cookie\Cookies;
use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -20,11 +22,10 @@
*/
final class StoreFbpSubscriber implements EventSubscriberInterface
{
private const COOKIE_NAME = '_fbp';

public function __construct(
private readonly FbpContextInterface $fbpContext,
private readonly ConsentCheckerInterface $consentChecker,
private readonly PixelProviderInterface $pixelProvider,
) {
}

Expand All @@ -41,33 +42,36 @@ public function store(ResponseEvent $event): void
return;
}

$response = $event->getResponse();

// There is no point in identifying a browser we have nowhere to send events to
if ([] === $this->pixelProvider->getPixels()) {
return;
}

if (!$this->consentChecker->isGranted()) {
return;
}

$fbp = $this->fbpContext->getFbp();

if (!$this->setCookie($event->getRequest(), $fbp)) {
if (!$this->shouldSetCookie($event->getRequest(), $fbp)) {
return;
}

$cookie = Cookie::create(
self::COOKIE_NAME,
$response->headers->setCookie(Cookie::create(
Cookies::FBP,
$fbp->value(),
new \DateTimeImmutable('+90 days'),
)
->withHttpOnly(false) // we need this to allow the js library to also use the cookie value
;

$event->getResponse()->headers->setCookie($cookie);
new \DateTimeImmutable(Cookies::LIFETIME),
)->withHttpOnly(false)); // we need this to allow the js library to also use the cookie value
}

/**
* Returns true if the cookie should be created/updated
*/
private function setCookie(Request $request, Fbp $fbp): bool
private function shouldSetCookie(Request $request, Fbp $fbp): bool
{
if (!$request->cookies->has(self::COOKIE_NAME)) {
if (!$request->cookies->has(Cookies::FBP)) {
return true;
}

Expand Down
2 changes: 2 additions & 0 deletions src/Resources/config/services/event_subscriber.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,15 @@
<service id="Setono\MetaConversionsApiBundle\EventSubscriber\StoreFbcSubscriber">
<argument type="service" id="Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface"/>

<tag name="kernel.event_subscriber"/>
</service>

<service id="Setono\MetaConversionsApiBundle\EventSubscriber\StoreFbpSubscriber">
<argument type="service" id="Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface"/>

<tag name="kernel.event_subscriber"/>
</service>
Expand Down
151 changes: 151 additions & 0 deletions tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Tests\Unit\EventSubscriber;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Setono\MetaConversionsApi\Pixel\Pixel;
use Setono\MetaConversionsApi\ValueObject\Fbc;
use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface;
use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface;
use Setono\MetaConversionsApiBundle\Cookie\Cookies;
use Setono\MetaConversionsApiBundle\EventSubscriber\StoreFbcSubscriber;
use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;

#[CoversClass(StoreFbcSubscriber::class)]
final class StoreFbcSubscriberTest extends TestCase
{
#[Test]
public function it_stores_the_click_id(): void
{
$event = self::event(new Request(['fbclid' => 'IwAR0rmfgHgx']), new Response());

self::subscriber()->store($event);

$cookie = self::cookie($event);
self::assertNotNull($cookie);
self::assertStringEndsWith('.IwAR0rmfgHgx', (string) $cookie->getValue());
// The browser pixel has to be able to read it
self::assertFalse($cookie->isHttpOnly());
}

#[Test]
public function it_does_nothing_without_a_click_id_on_the_request(): void
{
$event = self::event(new Request(), new Response());

self::subscriber()->store($event);

self::assertNull(self::cookie($event));
}

#[Test]
public function it_does_nothing_without_consent(): void
{
$event = self::event(new Request(['fbclid' => 'IwAR0rmfgHgx']), new Response());

self::subscriber(consentGranted: false)->store($event);

self::assertNull(self::cookie($event));
}

#[Test]
public function it_does_nothing_without_pixels(): void
{
// A Set-Cookie header makes the response uncacheable, and there is nowhere to send events to anyway
$event = self::event(new Request(['fbclid' => 'IwAR0rmfgHgx']), new Response());

self::subscriber(pixels: [])->store($event);

self::assertNull(self::cookie($event));
}

#[Test]
public function it_does_nothing_when_the_context_has_no_fbc(): void
{
$event = self::event(new Request(['fbclid' => 'IwAR0rmfgHgx']), new Response());

self::subscriber(fbc: null)->store($event);

self::assertNull(self::cookie($event));
}

#[Test]
public function it_ignores_sub_requests(): void
{
$event = self::event(new Request(['fbclid' => 'IwAR0rmfgHgx']), new Response(), HttpKernelInterface::SUB_REQUEST);

self::subscriber()->store($event);

self::assertNull(self::cookie($event));
}

private static function cookie(ResponseEvent $event): ?Cookie
{
foreach ($event->getResponse()->headers->getCookies() as $cookie) {
if (Cookies::FBC === $cookie->getName()) {
return $cookie;
}
}

return null;
}

/**
* @param list<Pixel>|null $pixels
*/
private static function subscriber(
?Fbc $fbc = new Fbc('IwAR0rmfgHgx'),
bool $consentGranted = true,
?array $pixels = null,
): StoreFbcSubscriber {
return new StoreFbcSubscriber(
new class($fbc) implements FbcContextInterface {
public function __construct(private readonly ?Fbc $fbc)
{
}

public function getFbc(): ?Fbc
{
return $this->fbc;
}
},
new class($consentGranted) implements ConsentCheckerInterface {
public function __construct(private readonly bool $granted)
{
}

public function isGranted(): bool
{
return $this->granted;
}
},
new class($pixels ?? [new Pixel('1234', 's3cr3t')]) implements PixelProviderInterface {
/**
* @param list<Pixel> $pixels
*/
public function __construct(private readonly array $pixels)
{
}

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

private static function event(Request $request, Response $response, int $requestType = HttpKernelInterface::MAIN_REQUEST): ResponseEvent
{
return new ResponseEvent(self::createStub(HttpKernelInterface::class), $request, $requestType, $response);
}
}
Loading
Loading