diff --git a/CLAUDE.md b/CLAUDE.md index 7d9a5c7..84f4af8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,15 @@ vendor/bin/phpunit --filter it_sends_event Tests use the `@test` annotation with `snake_case` method names (no `test` prefix). -CI (`.github/workflows/build.yaml`) runs coding standards, dependency analysis, PHPStan, and PHPUnit against PHP 8.1–8.4 on both `lowest` and `highest` dependency versions, so check lowest-version compatibility when touching dependencies. A separate workflow runs Roave's backwards-compatibility check on PRs — this is a public library, so avoid BC breaks to the public API. +CI (`.github/workflows/build.yaml`) runs coding standards, dependency analysis, PHPStan, and PHPUnit against PHP 8.1–8.4 on both `lowest` and `highest` dependency versions, so check lowest-version compatibility when touching dependencies. A separate workflow runs Roave's backwards-compatibility check on PRs, comparing against the PR's base branch. + +### Branches + +There is no `master`. **`1.x`** is the default branch and holds the released 1.x line: bug fixes and additive changes only, and the BC check must stay green — this is a public library. **`2.x`** is the next major: BC breaks are allowed there, but every one must be documented in `UPGRADE-2.0.md`. Because the BC check compares against the PR's base, it is expected to be red on `2.x` PRs that break BC; its output should match what `UPGRADE-2.0.md` lists. Always pass `--base` to `gh pr create`. `Closes #123` only auto-closes issues when merged into the default branch, so issues fixed on `2.x` have to be closed by hand. ### LiveClientTest -`tests/Client/LiveClientTest.php` hits the real Meta API. It self-skips unless the env vars in `phpunit.xml.dist` are set (`PIXEL_ID`, `ACCESS_TOKEN`, `TEST_EVENT_CODE`, `URL`, `EMAIL`). Copy `phpunit.xml.dist` to `phpunit.xml` and fill them in to run it. +`tests/Client/LiveClientTest.php` hits the real Meta API. It self-skips unless the env vars in `phpunit.xml.dist` are set (`PIXEL_ID`, `ACCESS_TOKEN`, `TEST_EVENT_CODE`, `URL`, `EMAIL`). Copy `phpunit.xml.dist` to `phpunit.xml` and fill them in to run it. With a filled-in `phpunit.xml`, every full `phpunit` run sends a real test event and Infection replays it for every mutant it covers — move `phpunit.xml` aside before running Infection. ## Architecture @@ -48,12 +52,12 @@ So to add a field: add the public property, map it in `getMapping()`, and regist **Two payload contexts** (`PAYLOAD_CONTEXT_SERVER` vs `PAYLOAD_CONTEXT_BROWSER`). The same objects serialize differently depending on whether they're sent server-side via the Conversions API or rendered into a client-side `fbq()` call. `User::getMapping()` strips server-only fields (IP, user agent, fbc, fbp) in browser context. -**`Parameters` subclasses:** `Event` (the aggregate root — holds `User $userData`, `Custom $customData`, a list of `Pixel`, plus `metadata` for app-internal use that is never sent), `User` (customer matching data), `Custom` (event-specific data like value/currency/contents). `Event` auto-generates `eventId` (random, for [deduplication](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event#event-id)) and `eventTime` in its constructor. `Event` is intentionally **not** `final` so consumers can subclass it into domain-specific events; the other data objects are `final`. +**`Parameters` subclasses:** `Event` (the aggregate root — holds `User $userData`, `Custom $customData`, a list of `Pixel`, plus `metadata` for app-internal use that is never sent), `User` (customer matching data), `Custom` (event-specific data like value/currency/contents), `Content` (a single item in `Custom::$contents`). `Event` auto-generates `eventId` (random, for [deduplication](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event#event-id)) and `eventTime` in its constructor. `Event` is intentionally **not** `final` so consumers can subclass it into domain-specific events; the other data objects are `final`. -**`Client` (`src/Client/Client.php`)** — `sendEvent()` serializes the event once, then POSTs it (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per associated pixel (each pixel carries its own access token). Non-200 responses throw `ClientException` built from `ErrorResponse`. HTTP is fully PSR-based: PSR-18 client and PSR-17 factories are auto-discovered via `php-http/discovery` but can be injected with `setHttpClient()` / `setRequestFactory()` / etc. The client is `LoggerAware` and defaults to `NullLogger`. +**`Client` (`src/Client/Client.php`)** — `sendEvent()` delegates to `sendPreparedEvent($event->prepare())`. `Event::prepare()` returns a `PreparedEvent` (`src/Event/PreparedEvent.php`): the `getPayload()` array plus the delivery information (event name and id, pixels, test event code), made of scalars/arrays/`Pixel` only so consumers can hash at capture time and queue it. The pixels are cloned so it is a snapshot, and `withoutAccessTokens()`/`withAccessTokens()` (immutable) keep the tokens out of the queue and restore them by pixel id before sending. `sendPreparedEvent()` POSTs the payload (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per pixel (each pixel carries its own access token). Non-200 responses throw `ClientException` built from `ErrorResponse`. HTTP is fully PSR-based: PSR-18 client and PSR-17 factories are auto-discovered via `php-http/discovery` but can be injected with `setHttpClient()` / `setRequestFactory()` / etc. The client is `LoggerAware` and defaults to `NullLogger`. **`FbqGenerator` (`src/Generator/FbqGenerator.php`)** — the client-side counterpart. Generates the `fbq('init', ...)` / `fbq('track', ...)` JavaScript snippets, using the browser-context payload and reusing the same `eventId` so server and browser events deduplicate. `Event::isCustom()` decides between `track` and `trackCustom`. -**Value objects (`src/ValueObject/`)** — `Fbc`/`Fbp` (extending `Fb`) model the `_fbc`/`_fbp` cookie values with `fromString()` validation and `value()` serialization; assignable to `User::$fbc`/`$fbp` as either the typed object or a raw string. +**Value objects (`src/ValueObject/`)** — `Fbc`/`Fbp` (extending `Fb`) model the `_fbc`/`_fbp` cookie values with `fromString()` validation and `value()` serialization; assignable to `User::$fbc`/`$fbp` as either the typed object or a raw string. Both accept the optional trailing appendix segment that Meta's parameter builder writes (`getAppendix()`/`withAppendix()`) and write it back unchanged, so a cookie value round-trips byte for byte. The `facebook/php-business-sdk` dependency is used only for `Normalizer`, `Util::hash`, and `ApiConfig::APIVersion` (the API version is pinned to whatever that package ships). diff --git a/README.md b/README.md index ef88dd5..c5a781d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ [![Code Coverage][ico-code-coverage]][link-code-coverage] [![Mutation testing][ico-infection]][link-infection] +> [!NOTE] +> This is the documentation for **2.x**, which is in development. The stable release lives on the +> [`1.x` branch](https://github.com/Setono/meta-conversions-api-php-sdk/tree/1.x). If you are upgrading, see +> [UPGRADE-2.0.md](UPGRADE-2.0.md). + A small, typed PHP library for sending server-side events to Meta's (Facebook's) [Conversions API](https://developers.facebook.com/docs/marketing-api/conversions-api), and for generating the matching browser-side `fbq()` snippets. @@ -32,6 +37,9 @@ way is to install it together with an implementation: composer require setono/meta-conversions-api-php-sdk kriswallsmith/buzz nyholm/psr7 ``` +2.0 is in pre-release. Until it is stable, ask for it explicitly, e.g. +`composer require setono/meta-conversions-api-php-sdk:^2.0@alpha`. + `symfony/http-client` works just as well if you prefer it: ```bash @@ -121,7 +129,7 @@ $event->testEventCode = 'TEST12345'; ### Error handling -`sendEvent()` throws a `ClientException` if Meta returns a non-2xx response. The message contains Meta's error message, +`sendEvent()` and `sendPreparedEvent()` throw a `ClientException` if Meta returns a non-2xx response. The message contains Meta's error message, code, trace id and the raw response (including the user-facing explanation when Meta provides one): ```php @@ -134,6 +142,28 @@ try { } ``` +## Sending events later, e.g. through a queue + +`User` holds the raw email addresses, phone numbers and names until the payload is built, so an `Event` should not be +queued as is. `Event::prepare()` returns a `PreparedEvent` instead: the payload, already normalized and hashed, together +with the pixels and the test event code. It is made of scalars, arrays and `Pixel` objects only, so it serializes with +the PHP serializer or the Symfony serializer without any tricks. Hash at capture time, send later: + +```php +// at capture time +$queue->push($event->prepare()->withoutAccessTokens()); + +// at send time +$preparedEvent = $queue->pop(); +$client->sendPreparedEvent($preparedEvent->withAccessTokens([ + 'your_pixel_id' => 'your_access_token', +])); +``` + +`withoutAccessTokens()` keeps the access tokens out of the queue and of any failure storage behind it, and +`withAccessTokens()` takes the tokens indexed by pixel id and leaves pixels that are not in the list as they are. Both +return a new instance. If your queue is trusted with the access tokens, you can skip both calls. + ## Browser-side tracking with deduplication To get the best match quality Meta recommends sending events both server-side (this SDK) *and* from the browser, using diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 0000000..bc0af84 --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,30 @@ +# Upgrade from 1.x to 2.0 + +The requirements are unchanged: PHP 8.1+ and the same dependencies as 1.x. + +## `ClientInterface` has a second method, `sendPreparedEvent()` + +This is the only backwards-compatibility break, and it only affects you if you implement `ClientInterface` yourself, +for instance in a decorator or a test double. Add: + +```php +use Setono\MetaConversionsApi\Event\PreparedEvent; + +public function sendPreparedEvent(PreparedEvent $preparedEvent): void; +``` + +It sends an event that was prepared earlier with `Event::prepare()`, i.e. with the payload already normalized and +hashed. `Client` implements it, and a decorator can simply forward the call. + +## Behaviour change in `Client::sendEvent()` + +`sendEvent()` now delegates to `sendPreparedEvent($event->prepare())`, so the payload is built before the client checks +whether the event has any pixels. The request that is sent is byte for byte the same as in 1.x. There is one observable +difference: an event without pixels whose data is invalid, an unknown `action_source` for instance, now throws when the +payload is built. In 1.x the client logged the missing pixels and returned without ever building the payload. + +## New in 2.0 + +Nothing you have to change, but worth knowing about: `Event::prepare()` returns a `PreparedEvent` that holds no raw +personal data and can be stored or queued, and `PreparedEvent::withoutAccessTokens()` / `withAccessTokens()` keep the +access tokens out of that storage. See "Sending events later, e.g. through a queue" in the README. diff --git a/src/Client/Client.php b/src/Client/Client.php index f563e08..07fcef2 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -14,6 +14,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\PreparedEvent; use Setono\MetaConversionsApi\Exception\ClientException; final class Client implements ClientInterface, LoggerAwareInterface @@ -33,7 +34,12 @@ public function __construct() public function sendEvent(Event $event): void { - if (!$event->hasPixels()) { + $this->sendPreparedEvent($event->prepare()); + } + + public function sendPreparedEvent(PreparedEvent $preparedEvent): void + { + if ([] === $preparedEvent->pixels) { $this->logger->error('You are trying to send events to Meta/Facebook, but you haven\'n associated any pixels with your event. This is most likely an error.'); return; @@ -42,16 +48,16 @@ public function sendEvent(Event $event): void $httpClient = $this->getHttpClient(); $requestFactory = $this->getRequestFactory(); - $data = json_encode([$event->getPayload()], \JSON_THROW_ON_ERROR); + $data = json_encode([$preparedEvent->payload], \JSON_THROW_ON_ERROR); - foreach ($event->pixels as $pixel) { + foreach ($preparedEvent->pixels as $pixel) { $body = [ 'access_token' => $pixel->accessToken, 'data' => $data, ]; - if (null !== $event->testEventCode) { - $body['test_event_code'] = $event->testEventCode; + if (null !== $preparedEvent->testEventCode) { + $body['test_event_code'] = $preparedEvent->testEventCode; } $request = $requestFactory->createRequest( diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php index 136a08b..d8ba241 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -5,6 +5,7 @@ namespace Setono\MetaConversionsApi\Client; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\PreparedEvent; use Setono\MetaConversionsApi\Exception\ClientException; /** @@ -16,4 +17,12 @@ interface ClientInterface * @throws ClientException if the request failed in any way */ public function sendEvent(Event $event): void; + + /** + * Sends an event that was prepared earlier with Event::prepare(). Use this when the personal data is hashed + * at capture time and the event is sent later, for instance through a queue + * + * @throws ClientException if the request failed in any way + */ + public function sendPreparedEvent(PreparedEvent $preparedEvent): void; } diff --git a/src/Event/Event.php b/src/Event/Event.php index ff1797a..c32a5a9 100644 --- a/src/Event/Event.php +++ b/src/Event/Event.php @@ -138,6 +138,22 @@ public function hasPixels(): bool return [] !== $this->pixels; } + /** + * Returns this event in its ready-to-send form: the payload is built, i.e. normalized and hashed, so the result + * holds no raw personal data and is safe to store or queue. Send it later with ClientInterface::sendPreparedEvent() + */ + public function prepare(): PreparedEvent + { + return new PreparedEvent( + $this->eventName, + $this->eventId, + $this->getPayload(), + // cloned so the prepared event is a snapshot: changing a pixel on either side must not change the other + array_map(static fn (Pixel $pixel): Pixel => clone $pixel, $this->pixels), + $this->testEventCode, + ); + } + /** * @return list */ diff --git a/src/Event/PreparedEvent.php b/src/Event/PreparedEvent.php new file mode 100644 index 0000000..366a491 --- /dev/null +++ b/src/Event/PreparedEvent.php @@ -0,0 +1,66 @@ + $payload the result of Event::getPayload() + * @param list $pixels the pixels the event should be sent to + */ + public function __construct( + public readonly string $eventName, + public readonly string $eventId, + public readonly array $payload, + public readonly array $pixels, + public readonly ?string $testEventCode = null, + ) { + } + + /** + * Returns a copy where the pixels carry no access tokens + */ + public function withoutAccessTokens(): self + { + return new self( + $this->eventName, + $this->eventId, + $this->payload, + array_map(static fn (Pixel $pixel): Pixel => new Pixel($pixel->id), $this->pixels), + $this->testEventCode, + ); + } + + /** + * Returns a copy where the pixels carry the given access tokens. Pixels that are not in the list are left as they are + * + * @param array $accessTokens the access tokens indexed by pixel id + */ + public function withAccessTokens(array $accessTokens): self + { + return new self( + $this->eventName, + $this->eventId, + $this->payload, + array_map( + static fn (Pixel $pixel): Pixel => new Pixel($pixel->id, $accessTokens[$pixel->id] ?? $pixel->accessToken), + $this->pixels, + ), + $this->testEventCode, + ); + } +} diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index d69d353..aeea6f6 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -18,6 +18,7 @@ use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\PreparedEvent; use Setono\MetaConversionsApi\Exception\ClientException; use Setono\MetaConversionsApi\Pixel\Pixel; use Setono\MetaConversionsApi\TestLogger; @@ -127,6 +128,116 @@ public function it_discovers_an_http_client_when_none_is_injected(): void self::assertCount(1, $httpClient->requests); } + /** + * @test + */ + public function it_sends_prepared_event(): void + { + $httpClient = new TestHttpClient(); + + $client = new Client(); + $client->setHttpClient($httpClient); + + $preparedEvent = new PreparedEvent( + Event::EVENT_PURCHASE, + 'event_id', + ['event_name' => 'Purchase', 'event_time' => 1658743659123, 'event_id' => 'event_id', 'action_source' => 'website'], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2', 'token_2')], + 'TEST123', + ); + $client->sendPreparedEvent($preparedEvent); + + self::assertCount(2, $httpClient->requests); + + [$first, $second] = $httpClient->requests; + self::assertSame('POST', $first->getMethod()); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_1/events', ApiConfig::APIVersion), (string) $first->getUri()); + self::assertSame( + 'access_token=token_1&data=%5B%7B%22event_name%22%3A%22Purchase%22%2C%22event_time%22%3A1658743659123%2C%22event_id%22%3A%22event_id%22%2C%22action_source%22%3A%22website%22%7D%5D&test_event_code=TEST123', + (string) $first->getBody(), + ); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_2/events', ApiConfig::APIVersion), (string) $second->getUri()); + self::assertStringContainsString('access_token=token_2', (string) $second->getBody()); + } + + /** + * @test + */ + public function it_sends_the_same_request_for_an_event_and_its_prepared_event(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 1658743659123; + $event->testEventCode = 'TEST123'; + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + $event->userData->email[] = 'johndoe@example.com'; + + $eventHttpClient = new TestHttpClient(); + $eventClient = new Client(); + $eventClient->setHttpClient($eventHttpClient); + $eventClient->sendEvent($event); + + $preparedEventHttpClient = new TestHttpClient(); + $preparedEventClient = new Client(); + $preparedEventClient->setHttpClient($preparedEventHttpClient); + $preparedEventClient->sendPreparedEvent($event->prepare()); + + self::assertCount(1, $eventHttpClient->requests); + self::assertCount(1, $preparedEventHttpClient->requests); + self::assertSame((string) $eventHttpClient->requests[0]->getUri(), (string) $preparedEventHttpClient->requests[0]->getUri()); + self::assertSame((string) $eventHttpClient->requests[0]->getBody(), (string) $preparedEventHttpClient->requests[0]->getBody()); + } + + /** + * @test + */ + public function it_sends_a_prepared_event_that_was_queued_without_its_access_tokens(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 1658743659123; + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + $event->userData->email[] = 'johndoe@example.com'; + + $eventHttpClient = new TestHttpClient(); + $eventClient = new Client(); + $eventClient->setHttpClient($eventHttpClient); + $eventClient->sendEvent($event); + + $queued = serialize($event->prepare()->withoutAccessTokens()); + self::assertStringNotContainsString('access_token', $queued); + + $preparedEvent = unserialize($queued); + self::assertInstanceOf(PreparedEvent::class, $preparedEvent); + + $preparedEventHttpClient = new TestHttpClient(); + $preparedEventClient = new Client(); + $preparedEventClient->setHttpClient($preparedEventHttpClient); + $preparedEventClient->sendPreparedEvent($preparedEvent->withAccessTokens(['pixel_id' => 'access_token'])); + + self::assertCount(1, $preparedEventHttpClient->requests); + self::assertSame((string) $eventHttpClient->requests[0]->getUri(), (string) $preparedEventHttpClient->requests[0]->getUri()); + self::assertSame((string) $eventHttpClient->requests[0]->getBody(), (string) $preparedEventHttpClient->requests[0]->getBody()); + } + + /** + * @test + */ + public function it_does_not_send_prepared_event_when_it_has_no_pixels(): void + { + $httpClient = new TestHttpClient(); + $logger = new TestLogger(); + + $client = new Client(); + $client->setHttpClient($httpClient); + $client->setLogger($logger); + + $client->sendPreparedEvent(new PreparedEvent(Event::EVENT_PURCHASE, 'event_id', [], [])); + + self::assertCount(0, $httpClient->requests); + self::assertTrue($logger->hasMessageMatching('#you haven\'n associated any pixels#')); + } + /** * @test */ diff --git a/tests/Event/EventTest.php b/tests/Event/EventTest.php index 27eeb16..32c09cb 100644 --- a/tests/Event/EventTest.php +++ b/tests/Event/EventTest.php @@ -223,4 +223,54 @@ public function it_rejects_an_invalid_action_source(): void $event->getPayload(); } + + /** + * @test + */ + public function it_prepares(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 123; + $event->testEventCode = 'TEST123'; + $event->pixels[] = new Pixel('pixel_1', 'token_1'); + $event->pixels[] = new Pixel('pixel_2'); + $event->userData->email[] = 'johndoe@example.com'; + + $preparedEvent = $event->prepare(); + + self::assertSame(Event::EVENT_PURCHASE, $preparedEvent->eventName); + self::assertSame('event_id', $preparedEvent->eventId); + self::assertSame('TEST123', $preparedEvent->testEventCode); + self::assertEquals($event->pixels, $preparedEvent->pixels); + self::assertSame($event->getPayload(), $preparedEvent->payload); + + // the name and id are duplicated from the payload on purpose + self::assertSame($preparedEvent->eventName, $preparedEvent->payload['event_name']); + self::assertSame($preparedEvent->eventId, $preparedEvent->payload['event_id']); + + // the personal data is hashed, i.e. the prepared event is safe to store + self::assertSame(['em' => ['55e79200c1635b37ad31a378c39feb12f120f116625093a19bc32fff15041149']], $preparedEvent->payload['user_data']); + self::assertStringNotContainsString('johndoe@example.com', serialize($preparedEvent)); + } + + /** + * @test + */ + public function it_prepares_a_snapshot(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + + $preparedEvent = $event->prepare(); + + $preparedPixel = $preparedEvent->pixels[0]; + self::assertNotSame($event->pixels[0], $preparedPixel); + + $preparedPixel->accessToken = 'changed on the prepared event'; + self::assertSame('access_token', $event->pixels[0]->accessToken); + + $event->pixels[0]->accessToken = 'changed on the event'; + self::assertSame('changed on the prepared event', $preparedPixel->accessToken); + } } diff --git a/tests/Event/PreparedEventTest.php b/tests/Event/PreparedEventTest.php new file mode 100644 index 0000000..769a9af --- /dev/null +++ b/tests/Event/PreparedEventTest.php @@ -0,0 +1,124 @@ + 'Purchase', 'event_id' => 'event_id', 'user_data' => ['em' => ['hashed']]], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2')], + 'TEST123', + ); + + $unserialized = unserialize(serialize($preparedEvent)); + + self::assertInstanceOf(PreparedEvent::class, $unserialized); + self::assertNotSame($preparedEvent, $unserialized); + self::assertEquals($preparedEvent, $unserialized); + self::assertSame('Purchase', $unserialized->eventName); + self::assertSame('event_id', $unserialized->eventId); + self::assertSame(['event_name' => 'Purchase', 'event_id' => 'event_id', 'user_data' => ['em' => ['hashed']]], $unserialized->payload); + self::assertSame('TEST123', $unserialized->testEventCode); + self::assertCount(2, $unserialized->pixels); + self::assertSame('pixel_1', $unserialized->pixels[0]->id); + self::assertSame('token_1', $unserialized->pixels[0]->accessToken); + self::assertSame('pixel_2', $unserialized->pixels[1]->id); + self::assertNull($unserialized->pixels[1]->accessToken); + } + + /** + * @test + */ + public function it_has_no_test_event_code_by_default(): void + { + $preparedEvent = new PreparedEvent(Event::EVENT_PURCHASE, 'event_id', [], []); + + self::assertNull($preparedEvent->testEventCode); + self::assertSame([], $preparedEvent->pixels); + } + + /** + * @test + */ + public function it_removes_the_access_tokens(): void + { + $preparedEvent = new PreparedEvent( + Event::EVENT_PURCHASE, + 'event_id', + ['event_name' => 'Purchase'], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2', 'token_2')], + 'TEST123', + ); + + $withoutAccessTokens = $preparedEvent->withoutAccessTokens(); + + self::assertNotSame($preparedEvent, $withoutAccessTokens); + self::assertEquals([new Pixel('pixel_1'), new Pixel('pixel_2')], $withoutAccessTokens->pixels); + self::assertStringNotContainsString('token_1', serialize($withoutAccessTokens)); + + // everything else is carried over + self::assertSame('Purchase', $withoutAccessTokens->eventName); + self::assertSame('event_id', $withoutAccessTokens->eventId); + self::assertSame(['event_name' => 'Purchase'], $withoutAccessTokens->payload); + self::assertSame('TEST123', $withoutAccessTokens->testEventCode); + + // the original is untouched + self::assertSame('token_1', $preparedEvent->pixels[0]->accessToken); + self::assertSame('token_2', $preparedEvent->pixels[1]->accessToken); + } + + /** + * @test + */ + public function it_adds_the_access_tokens_by_pixel_id(): void + { + $preparedEvent = new PreparedEvent( + Event::EVENT_PURCHASE, + 'event_id', + ['event_name' => 'Purchase'], + [new Pixel('1234567890'), new Pixel('pixel_2'), new Pixel('pixel_3', 'existing_token')], + 'TEST123', + ); + + // pixel ids are numeric strings in practice, which PHP turns into integer array keys + $withAccessTokens = $preparedEvent->withAccessTokens(['1234567890' => 'token_1', 'pixel_2' => 'token_2']); + + self::assertNotSame($preparedEvent, $withAccessTokens); + self::assertEquals( + [new Pixel('1234567890', 'token_1'), new Pixel('pixel_2', 'token_2'), new Pixel('pixel_3', 'existing_token')], + $withAccessTokens->pixels, + ); + + // everything else is carried over + self::assertSame('Purchase', $withAccessTokens->eventName); + self::assertSame('event_id', $withAccessTokens->eventId); + self::assertSame(['event_name' => 'Purchase'], $withAccessTokens->payload); + self::assertSame('TEST123', $withAccessTokens->testEventCode); + + // the original is untouched + self::assertNull($preparedEvent->pixels[0]->accessToken); + self::assertNull($preparedEvent->pixels[1]->accessToken); + } + + /** + * @test + */ + public function it_replaces_an_existing_access_token(): void + { + $preparedEvent = new PreparedEvent(Event::EVENT_PURCHASE, 'event_id', [], [new Pixel('pixel_id', 'old_token')]); + + self::assertEquals([new Pixel('pixel_id', 'new_token')], $preparedEvent->withAccessTokens(['pixel_id' => 'new_token'])->pixels); + } +}