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); + } +}