diff --git a/test/EventSnapshotsTest.php b/test/EventSnapshotsTest.php new file mode 100644 index 0000000..82df738 --- /dev/null +++ b/test/EventSnapshotsTest.php @@ -0,0 +1,173 @@ +httpClient = new MockedHttpClient('snapshot.posthog.test'); + $this->client = new Client( + self::API_KEY, + [ + 'batch_size' => 100, + 'debug' => true, + ], + $this->httpClient, + null, + false + ); + PostHog::init(null, null, $this->client); + } + + protected function tearDown(): void + { + $this->client->shutdown(); + Clock::set(new NativeClock()); + } + + public function testEventFamilyBatchRequestMatchesSnapshot(): void + { + self::assertTrue($this->client->capture([ + 'distinctId' => 'person-123', + 'event' => 'checkout completed', + 'groups' => [ + 'organization' => 'org-42', + 'project' => 'project-7', + ], + 'properties' => [ + 'boolean_false' => false, + 'boolean_true' => true, + 'empty_list' => [], + 'empty_object' => (object) [], + 'empty_string' => '', + 'float' => 42.5, + 'floating_zero' => 0.0, + 'integer' => 42, + 'large_integer' => 9007199254740991, + 'negative_floating_zero' => -0.0, + 'negative_integer' => -42, + 'list_order' => ['third', 'first', 'second'], + 'nested_object' => [ + 'zeta' => null, + 'alpha' => 'first', + ], + 'null' => null, + 'numeric_string' => '0', + 'text' => "line one\n\"quoted\" \\ slash / café 🌍", + 'zero' => 0, + ], + ])); + + self::assertTrue($this->client->identify([ + 'distinctId' => 'person-123', + 'properties' => [ + 'email' => 'person@example.com', + 'roles' => ['admin', 'editor'], + 'preferences' => (object) [], + ], + ])); + + self::assertTrue($this->client->alias([ + 'distinctId' => 'person-123', + 'alias' => 'anonymous-456', + 'properties' => [ + 'source' => 'snapshot-suite', + ], + ])); + + self::assertTrue(PostHog::groupIdentify([ + 'groupType' => 'organization', + 'groupKey' => 'org-42', + 'properties' => [ + 'employees' => 150, + 'labels' => ['customer', 'beta'], + 'metadata' => (object) [], + 'name' => 'PostHog', + ], + ])); + + self::assertTrue($this->client->flush()); + self::assertCount(1, $this->httpClient->calls); + + $this->assertJsonSnapshot( + 'event-family-request.json', + $this->structuredRequest($this->httpClient->calls[0]) + ); + } + + public function testCompleteFlagsRequestMatchesSnapshot(): void + { + $this->client->flags( + 'person-123', + [ + 'organization' => 'org-42', + 'project' => 'project-7', + ], + [ + 'empty_list' => [], + 'empty_object' => (object) [], + 'nullable' => null, + 'plan' => 'enterprise', + 'roles' => ['admin', 'editor'], + ], + [ + 'organization' => [ + 'employee_count' => 150, + 'industry' => 'analytics', + 'settings' => (object) [], + ], + 'project' => [ + 'enabled' => false, + 'tags' => ['php', 'server'], + ], + ], + true, + ['checkout-redesign', 'server-rollout'] + ); + + self::assertCount(1, $this->httpClient->calls); + $this->assertJsonSnapshot( + 'flags-request.json', + $this->structuredRequest($this->httpClient->calls[0]) + ); + } + + /** + * @param array{ + * path: string, + * payload: string, + * extraHeaders: array, + * requestOptions: array + * } $call + * @return array + */ + private function structuredRequest(array $call): array + { + $headers = []; + foreach ($call['extraHeaders'] as $header) { + [$name, $value] = explode(':', $header, 2); + $headers[trim($name)] = trim($value); + } + + return [ + 'body' => json_decode($call['payload'], false, 512, JSON_THROW_ON_ERROR), + 'headers' => $headers, + 'options' => $call['requestOptions'], + 'path' => $call['path'], + ]; + } +} diff --git a/test/JsonSnapshotTestCase.php b/test/JsonSnapshotTestCase.php new file mode 100644 index 0000000..ce487a0 --- /dev/null +++ b/test/JsonSnapshotTestCase.php @@ -0,0 +1,100 @@ + */ + private array $uuidPlaceholders = []; + + protected function assertJsonSnapshot(string $name, mixed $actual): void + { + $this->uuidPlaceholders = []; + $normalized = $this->normalizeValue($actual); + $rendered = json_encode( + $normalized, + JSON_PRETTY_PRINT + | JSON_UNESCAPED_SLASHES + | JSON_UNESCAPED_UNICODE + | JSON_PRESERVE_ZERO_FRACTION + | JSON_THROW_ON_ERROR + ) . "\n"; + $path = __DIR__ . '/assests/snapshots/' . $name; + + if (getenv('UPDATE_EVENT_SHAPE_SNAPSHOTS') === '1') { + file_put_contents($path, $rendered); + } + + self::assertFileExists($path, "Missing snapshot {$name}"); + self::assertSame( + file_get_contents($path), + $rendered, + "Snapshot {$name} changed. Re-record with UPDATE_EVENT_SHAPE_SNAPSHOTS=1." + ); + } + + private function normalizeValue(mixed $value, ?string $key = null): mixed + { + if ($key === 'api_key' || $key === 'personal_api_key' || $key === 'secret_key') { + return ''; + } + + if ($key === '$lib_version') { + return ''; + } + + if (is_string($value)) { + if (preg_match('/^posthog-php\/.+$/', $value) === 1) { + return 'posthog-php/'; + } + + if ($this->isUuid($value)) { + if (!isset($this->uuidPlaceholders[$value])) { + $this->uuidPlaceholders[$value] = 'uuidPlaceholders) + 1) . '>'; + } + + return $this->uuidPlaceholders[$value]; + } + + return $value; + } + + if ($value instanceof stdClass) { + $properties = get_object_vars($value); + ksort($properties, SORT_STRING); + $normalized = new stdClass(); + foreach ($properties as $property => $propertyValue) { + $normalized->{$property} = $this->normalizeValue($propertyValue, $property); + } + + return $normalized; + } + + if (is_array($value)) { + if (array_is_list($value)) { + return array_map(fn(mixed $item): mixed => $this->normalizeValue($item), $value); + } + + ksort($value, SORT_STRING); + $normalized = []; + foreach ($value as $property => $propertyValue) { + $normalized[$property] = $this->normalizeValue($propertyValue, (string) $property); + } + + return $normalized; + } + + return $value; + } + + private function isUuid(string $value): bool + { + return preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $value + ) === 1; + } +} diff --git a/test/assests/snapshots/event-family-request.json b/test/assests/snapshots/event-family-request.json new file mode 100644 index 0000000..64b593b --- /dev/null +++ b/test/assests/snapshots/event-family-request.json @@ -0,0 +1,127 @@ +{ + "body": { + "api_key": "", + "batch": [ + { + "$groups": { + "organization": "org-42", + "project": "project-7" + }, + "distinct_id": "person-123", + "event": "checkout completed", + "groups": { + "organization": "org-42", + "project": "project-7" + }, + "properties": { + "$groups": { + "organization": "org-42", + "project": "project-7" + }, + "$is_server": true, + "$lib": "posthog-php", + "$lib_consumer": "LibCurl", + "$lib_version": "", + "boolean_false": false, + "boolean_true": true, + "empty_list": [], + "empty_object": {}, + "empty_string": "", + "float": 42.5, + "floating_zero": 0, + "integer": 42, + "large_integer": 9007199254740991, + "list_order": [ + "third", + "first", + "second" + ], + "negative_floating_zero": 0, + "negative_integer": -42, + "nested_object": { + "alpha": "first", + "zeta": null + }, + "null": null, + "numeric_string": "0", + "text": "line one\n\"quoted\" \\ slash / café 🌍", + "zero": 0 + }, + "timestamp": "2024-01-02T03:04:05.123456+00:00", + "uuid": "" + }, + { + "$set": { + "email": "person@example.com", + "preferences": {}, + "roles": [ + "admin", + "editor" + ] + }, + "distinct_id": "person-123", + "event": "$identify", + "groups": [], + "properties": { + "$is_server": true, + "$lib": "posthog-php", + "$lib_consumer": "LibCurl", + "$lib_version": "", + "email": "person@example.com", + "preferences": {}, + "roles": [ + "admin", + "editor" + ] + }, + "timestamp": "2024-01-02T03:04:05.123456+00:00" + }, + { + "distinct_id": null, + "event": "$create_alias", + "groups": [], + "properties": { + "$is_server": true, + "$lib": "posthog-php", + "$lib_consumer": "LibCurl", + "$lib_version": "", + "alias": "anonymous-456", + "distinct_id": "person-123", + "source": "snapshot-suite" + }, + "timestamp": "2024-01-02T03:04:05.123456+00:00" + }, + { + "distinct_id": "$organization_org-42", + "event": "$groupidentify", + "groups": [], + "properties": { + "$group_key": "org-42", + "$group_set": { + "employees": 150, + "labels": [ + "customer", + "beta" + ], + "metadata": {}, + "name": "PostHog" + }, + "$group_type": "organization", + "$is_server": true, + "$lib": "posthog-php", + "$lib_consumer": "LibCurl", + "$lib_version": "" + }, + "timestamp": "2024-01-02T03:04:05.123456+00:00", + "uuid": "" + } + ] + }, + "headers": { + "User-Agent": "posthog-php/" + }, + "options": { + "shouldVerify": true + }, + "path": "/batch/" +} diff --git a/test/assests/snapshots/flags-request.json b/test/assests/snapshots/flags-request.json new file mode 100644 index 0000000..1e0dcb3 --- /dev/null +++ b/test/assests/snapshots/flags-request.json @@ -0,0 +1,47 @@ +{ + "body": { + "api_key": "", + "distinct_id": "person-123", + "flag_keys_to_evaluate": [ + "checkout-redesign", + "server-rollout" + ], + "geoip_disable": true, + "group_properties": { + "organization": { + "employee_count": 150, + "industry": "analytics", + "settings": {} + }, + "project": { + "enabled": false, + "tags": [ + "php", + "server" + ] + } + }, + "groups": { + "organization": "org-42", + "project": "project-7" + }, + "person_properties": { + "empty_list": [], + "empty_object": {}, + "nullable": null, + "plan": "enterprise", + "roles": [ + "admin", + "editor" + ] + } + }, + "headers": { + "User-Agent": "posthog-php/" + }, + "options": { + "shouldRetry": false, + "timeout": 3000 + }, + "path": "/flags/?v=2" +}