diff --git a/src/Context/Fbc/CookieBasedFbcContext.php b/src/Context/Fbc/CookieBasedFbcContext.php
index a648dc5..5b0bad8 100644
--- a/src/Context/Fbc/CookieBasedFbcContext.php
+++ b/src/Context/Fbc/CookieBasedFbcContext.php
@@ -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
@@ -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;
}
diff --git a/src/Context/Fbp/CookieBasedFbpContext.php b/src/Context/Fbp/CookieBasedFbpContext.php
index b25962e..8244cb5 100644
--- a/src/Context/Fbp/CookieBasedFbpContext.php
+++ b/src/Context/Fbp/CookieBasedFbpContext.php
@@ -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
@@ -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();
}
diff --git a/src/Cookie/Cookies.php b/src/Cookie/Cookies.php
new file mode 100644
index 0000000..56f1676
--- /dev/null
+++ b/src/Cookie/Cookies.php
@@ -0,0 +1,26 @@
+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;
}
@@ -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
}
}
diff --git a/src/EventSubscriber/StoreFbpSubscriber.php b/src/EventSubscriber/StoreFbpSubscriber.php
index b2ff8e6..733805f 100644
--- a/src/EventSubscriber/StoreFbpSubscriber.php
+++ b/src/EventSubscriber/StoreFbpSubscriber.php
@@ -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;
@@ -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,
) {
}
@@ -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;
}
diff --git a/src/Resources/config/services/event_subscriber.xml b/src/Resources/config/services/event_subscriber.xml
index 290588f..475ad3b 100644
--- a/src/Resources/config/services/event_subscriber.xml
+++ b/src/Resources/config/services/event_subscriber.xml
@@ -63,6 +63,7 @@
+
@@ -70,6 +71,7 @@
+
diff --git a/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php b/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php
new file mode 100644
index 0000000..fcf43ff
--- /dev/null
+++ b/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php
@@ -0,0 +1,151 @@
+ '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|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 $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);
+ }
+}
diff --git a/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php b/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php
new file mode 100644
index 0000000..edad9bc
--- /dev/null
+++ b/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php
@@ -0,0 +1,162 @@
+store($event);
+
+ self::assertNotNull(self::cookie($event));
+ }
+
+ #[Test]
+ public function it_makes_the_cookie_readable_by_the_browser_pixel(): void
+ {
+ $event = self::event(new Request(), new Response());
+
+ self::subscriber()->store($event);
+
+ $cookie = self::cookie($event);
+ self::assertNotNull($cookie);
+ self::assertFalse($cookie->isHttpOnly());
+ }
+
+ #[Test]
+ public function it_does_not_set_the_cookie_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(), new Response());
+
+ self::subscriber(pixels: [])->store($event);
+
+ self::assertNull(self::cookie($event));
+ }
+
+ #[Test]
+ public function it_does_not_set_the_cookie_without_consent(): void
+ {
+ $event = self::event(new Request(), new Response());
+
+ self::subscriber(consentGranted: false)->store($event);
+
+ self::assertNull(self::cookie($event));
+ }
+
+ #[Test]
+ public function it_does_not_set_the_cookie_on_a_sub_request(): void
+ {
+ $event = self::event(new Request(), new Response(), HttpKernelInterface::SUB_REQUEST);
+
+ self::subscriber()->store($event);
+
+ self::assertNull(self::cookie($event));
+ }
+
+ #[Test]
+ public function it_does_not_renew_a_fresh_cookie(): void
+ {
+ $fbp = Fbp::fromString(sprintf('fb.1.%d000.1088522659', time() - 60));
+ $request = new Request([], [], [], [Cookies::FBP => $fbp->value()]);
+
+ $event = self::event($request, new Response());
+
+ self::subscriber(fbp: $fbp)->store($event);
+
+ self::assertNull(self::cookie($event));
+ }
+
+ #[Test]
+ public function it_renews_a_cookie_older_than_two_hours(): void
+ {
+ $fbp = Fbp::fromString(sprintf('fb.1.%d000.1088522659', time() - 7300));
+ $request = new Request([], [], [], [Cookies::FBP => $fbp->value()]);
+
+ $event = self::event($request, new Response());
+
+ self::subscriber(fbp: $fbp)->store($event);
+
+ self::assertNotNull(self::cookie($event));
+ }
+
+ private static function cookie(ResponseEvent $event): ?Cookie
+ {
+ foreach ($event->getResponse()->headers->getCookies() as $cookie) {
+ if (Cookies::FBP === $cookie->getName()) {
+ return $cookie;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param list|null $pixels
+ */
+ private static function subscriber(?Fbp $fbp = null, bool $consentGranted = true, ?array $pixels = null): StoreFbpSubscriber
+ {
+ return new StoreFbpSubscriber(
+ new class($fbp ?? new Fbp()) implements FbpContextInterface {
+ public function __construct(private readonly Fbp $fbp)
+ {
+ }
+
+ public function getFbp(): Fbp
+ {
+ return $this->fbp;
+ }
+ },
+ 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 $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);
+ }
+}