diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 520003e..4b74b83 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -7,6 +7,7 @@ on: - main paths: - "**.php" + - "sdk_compliance_adapter/**" - "bin/posthog" - "composer.json" - "composer.lock" @@ -99,6 +100,10 @@ jobs: if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: ./vendor/bin/phpunit --bootstrap vendor/autoload.php --configuration phpunit.xml + - name: Run adapter fidelity and report checker tests + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: python3 -m unittest discover -s sdk_compliance_adapter -v + coverage: runs-on: ubuntu-latest needs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e00baee..a87dc1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,14 @@ Run the test command used in CI: XDEBUG_MODE=coverage ./vendor/bin/phpunit --bootstrap vendor/autoload.php --configuration phpunit.xml --coverage-text ``` +The PHPUnit transport tests use a loopback HTTP server, not live PostHog endpoints. They require the cURL extension, the `curl` and `gzip` commands, and permission to start PHP subprocesses and listen on loopback ports. + +Run the adapter fidelity and report checker tests (Python standard library only): + +```bash +python3 -m unittest discover -s sdk_compliance_adapter -v +``` + CI also runs PHP_CodeSniffer with `phpcs.xml`. You can run an equivalent local check with: ```bash diff --git a/sdk_compliance_adapter/test_server.py b/sdk_compliance_adapter/test_server.py index 0224c32..1d72501 100644 --- a/sdk_compliance_adapter/test_server.py +++ b/sdk_compliance_adapter/test_server.py @@ -12,8 +12,8 @@ from server import Controller, Relay -MOCK_PORT = int(os.environ.get("TEST_MOCK_PORT", "19276")) -PROXY_PORT = int(os.environ.get("TEST_PROXY_PORT", "19277")) +MOCK_PORT = int(os.environ.get("TEST_MOCK_PORT", "0")) +PROXY_PORT = int(os.environ.get("TEST_PROXY_PORT", "0")) class MockHandler(BaseHTTPRequestHandler): @@ -33,21 +33,19 @@ def log_message(self, *_): class AdapterTest(unittest.TestCase): def setUp(self): + self.environment = patch.dict(os.environ, {"PROXY_PORT": str(PROXY_PORT)}) + self.environment.start() + self.addCleanup(self.environment.stop) self.mock = ThreadingHTTPServer(("127.0.0.1", MOCK_PORT), MockHandler) + self.addCleanup(self.mock.server_close) self.mock.requests = [] self.mock.statuses = [] self.thread = threading.Thread(target=self.mock.serve_forever, daemon=True) self.thread.start() - self.environment = patch.dict(os.environ, {"PROXY_PORT": str(PROXY_PORT)}) - self.environment.start() + self.addCleanup(self.thread.join) + self.addCleanup(self.mock.shutdown) self.controller = Controller() - - def tearDown(self): - self.controller.reset() - self.mock.shutdown() - self.mock.server_close() - self.thread.join() - self.environment.stop() + self.addCleanup(self.controller.reset) def call(self, path, data=None): status, result = self.controller.handle("GET" if data is None else "POST", path, data or {}) @@ -58,7 +56,7 @@ def init(self, consumer="lib_curl", **options): os.environ["POSTHOG_CONSUMER"] = consumer self.mock.requests.clear() self.mock.statuses.clear() - self.call("/init", {"host": f"http://127.0.0.1:{MOCK_PORT}", + self.call("/init", {"host": f"http://127.0.0.1:{self.mock.server_address[1]}", "api_key": "phc_local_test", **options}) def capture(self): @@ -172,7 +170,7 @@ def accept(): relay = Relay(("127.0.0.1", PROXY_PORT), target.getsockname()) relay_thread = threading.Thread(target=relay.serve_forever) relay_thread.start() - with socket.create_connection(relay.server_address) as client: + with socket.create_connection(relay.server_address, timeout=5) as client: self.assertTrue(accepted.wait(5)) relay.shutdown() relay.server_close() @@ -207,7 +205,7 @@ def respond(): relay_thread = threading.Thread(target=relay.serve_forever) relay_thread.start() try: - with socket.create_connection(relay.server_address) as client: + with socket.create_connection(relay.server_address, timeout=5) as client: client.sendall(request) data = b"" while True: diff --git a/test/ClockMockTrait.php b/test/ClockMockTrait.php index 0b9c30c..1c335f2 100644 --- a/test/ClockMockTrait.php +++ b/test/ClockMockTrait.php @@ -4,7 +4,6 @@ use Symfony\Component\Clock\Clock; use Symfony\Component\Clock\MockClock; -use Symfony\Component\Clock\NativeClock; /** * Trait providing time mocking functionality for tests using Symfony Clock. @@ -26,13 +25,13 @@ protected function executeAtFrozenDateTime(\DateTimeInterface $dateTime, callabl ? $dateTime : \DateTimeImmutable::createFromInterface($dateTime)); + $previousClock = Clock::get(); Clock::set($mockClock); try { return $callback(); } finally { - // Reset to real clock - Clock::set(new NativeClock()); + Clock::set($previousClock); } } } diff --git a/test/ClockMockTraitTest.php b/test/ClockMockTraitTest.php new file mode 100644 index 0000000..9008142 --- /dev/null +++ b/test/ClockMockTraitTest.php @@ -0,0 +1,40 @@ +executeAtFrozenDateTime(new \DateTimeImmutable('2025-01-01 UTC'), function (): string { + $inner = Clock::get(); + try { + $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-01-01 UTC'), function (): void { + self::assertSame('2026-01-01', Clock::get()->now()->format('Y-m-d')); + throw new RuntimeException('callback failed'); + }); + self::fail('Expected callback exception'); + } catch (RuntimeException $exception) { + self::assertSame('callback failed', $exception->getMessage()); + } + self::assertSame($inner, Clock::get()); + return Clock::get()->now()->format('Y-m-d'); + }); + self::assertSame('2025-01-01', $value); + self::assertSame($outer, Clock::get()); + } finally { + Clock::set($original); + } + } +} diff --git a/test/ConsumerFileTest.php b/test/ConsumerFileTest.php index 6e35cd9..136c4b4 100644 --- a/test/ConsumerFileTest.php +++ b/test/ConsumerFileTest.php @@ -4,30 +4,22 @@ use PHPUnit\Framework\TestCase; use PostHog\Client; +use PostHog\PostHog; class ConsumerFileTest extends TestCase { - private $client; - private $filename = "/tmp/posthog.log"; + private Client $client; + private string $filename; public function setUp(): void { - date_default_timezone_set("UTC"); - if (file_exists($this->filename())) { - unlink($this->filename()); - } - - $this->client = new Client( - "BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", - array( - "consumer" => "file", - "filename" => $this->filename, - ) - ); + $this->filename = tempnam(sys_get_temp_dir(), 'posthog-file-test-'); + $this->client = new Client('test-key', ['consumer' => 'file', 'filename' => $this->filename]); } public function tearDown(): void { + $this->client->shutdown(); if (file_exists($this->filename)) { unlink($this->filename); } @@ -35,93 +27,109 @@ public function tearDown(): void public function testCapture(): void { - self::assertTrue( - $this->client->capture( - array( - "distinctId" => "some-user", - "event" => "File PHP Event - Microtime", - "timestamp" => time(), - ) - ) - ); - $this->checkWritten("File PHP Event - Microtime"); + self::assertTrue($this->client->capture([ + 'distinctId' => 'some-user', + 'event' => 'File PHP Event - Microtime', + 'timestamp' => 1704067200, + ])); + $event = $this->writtenEvent('File PHP Event - Microtime'); + self::assertSame('some-user', $event['distinct_id']); + self::assertSame('2024-01-01T00:00:00+00:00', $event['timestamp']); } public function testIdentify(): void { - self::assertTrue( - $this->client->identify( - array( - "distinctId" => "Calvin", - "properties" => array( - "loves_php" => false, - "type" => "posthog.log", - "birthday" => time(), - ), - ) - ) - ); - $this->checkWritten('$identify'); + self::assertTrue($this->client->identify([ + 'distinctId' => 'Calvin', + 'properties' => ['loves_php' => false, 'birthday' => 1704067200], + ])); + $event = $this->writtenEvent('$identify'); + self::assertSame('Calvin', $event['distinct_id']); + self::assertFalse($event['properties']['loves_php']); + self::assertSame(1704067200, $event['properties']['birthday']); } public function testAlias(): void { - self::assertTrue( - $this->client->alias( - array( - "alias" => "previous-id", - "distinctId" => "user-id", - ) - ) - ); - - $this->checkWritten('$create_alias'); + self::assertTrue($this->client->alias(['alias' => 'previous-id', 'distinctId' => 'user-id'])); + $event = $this->writtenEvent('$create_alias'); + self::assertSame('previous-id', $event['properties']['alias']); + self::assertSame('user-id', $event['properties']['distinct_id']); } public function testSend(): void { for ($i = 0; $i < 200; ++$i) { - $this->client->capture( - array( - "distinctId" => "distinctId", - "event" => "event", - ) + self::assertTrue($this->client->capture(['distinctId' => 'distinctId', 'event' => "event-$i"])); + } + $this->client->shutdown(); + $server = new LocalHttpServer(); + try { + $process = proc_open( + [PHP_BINARY, __DIR__ . '/../send.php', '--apiKey', 'test-key', '--file', $this->filename], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + array_merge(getenv(), [PostHog::ENV_HOST => 'http://' . $server->address()]) + ); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $errors); + self::assertSame('sent 200 from 200 requests successfully', trim($output)); + self::assertFileDoesNotExist($this->filename); + $requests = $server->requests(); + self::assertCount(2, $requests); + $events = []; + foreach ($requests as $request) { + self::assertSame('POST /batch/ HTTP/1.1', $request['requestLine']); + $payload = json_decode($request['body'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame('test-key', $payload['api_key']); + self::assertCount(100, $payload['batch']); + $events = array_merge($events, $payload['batch']); + } + self::assertSame( + array_map(static fn(int $i): string => "event-$i", range(0, 199)), + array_column($events, 'event') ); + self::assertSame(array_fill(0, 200, 'distinctId'), array_column($events, 'distinct_id')); + } finally { + $server->stop(); } - exec("php send.php --apiKey BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg --file /tmp/posthog.log", $output); - self::assertSame("sent 200 from 200 requests successfully", trim(implode('', $output))); - self::assertFileDoesNotExist($this->filename()); } public function testProductionProblems(): void { - // Open to a place where we should not have write access. - $client = new Client( - "BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", - array( - "consumer" => "file", - "filename" => "/dev/x/xxxxxxx", - ) - ); - - $captured = $client->capture(array("distinctId" => "some-user", "event" => "my event")); - self::assertFalse($captured); - } - - private function checkWritten($event): void - { - exec("wc -l " . $this->filename, $output); - $out = trim($output[0]); - self::assertSame($out, "1 " . $this->filename); - $str = file_get_contents($this->filename); - $json = json_decode(trim($str)); - self::assertObjectNotHasProperty('type', $json); - self::assertSame($event, $json->event); - unlink($this->filename); + $warnings = []; + set_error_handler(static function (int $severity, string $message) use (&$warnings): bool { + $warnings[] = [$severity, $message]; + return true; + }, E_WARNING); + try { + $client = new Client('test-key', [ + 'consumer' => 'file', + 'filename' => $this->filename . '/not-a-directory', + ]); + } finally { + restore_error_handler(); + } + self::assertCount(2, $warnings); + self::assertSame(E_WARNING, $warnings[0][0]); + self::assertStringContainsString('fopen(', $warnings[0][1]); + self::assertStringContainsString('chmod(', $warnings[1][1]); + self::assertFalse($client->capture(['distinctId' => 'some-user', 'event' => 'my event'])); } - public function filename(): string + private function writtenEvent(string $name): array { - return '/tmp/posthog.log'; + $lines = file($this->filename, FILE_IGNORE_NEW_LINES); + self::assertCount(1, $lines); + $event = json_decode($lines[0], true, 512, JSON_THROW_ON_ERROR); + self::assertArrayNotHasKey('type', $event); + self::assertSame($name, $event['event']); + return $event; } } diff --git a/test/ConsumerForkCurlTest.php b/test/ConsumerForkCurlTest.php index 0e0536e..6657d60 100644 --- a/test/ConsumerForkCurlTest.php +++ b/test/ConsumerForkCurlTest.php @@ -2,62 +2,10 @@ namespace PostHog\Test; -use PHPUnit\Framework\TestCase; -use PostHog\Client; - -class ConsumerForkCurlTest extends TestCase +class ConsumerForkCurlTest extends ConsumerTransportTestCase { - private $client; - - public function setUp(): void - { - date_default_timezone_set("UTC"); - $this->client = new Client( - "OnMMoZ6YVozrgSBeZ9FpkC0ixH0ycYZn", - array( - "consumer" => "fork_curl", - "debug" => true, - ) - ); - } - - public function testCapture(): void - { - self::assertTrue( - $this->client->capture( - array( - "distinctId" => "some-user", - "event" => "PHP Fork Curl'd\" Event", - ) - ) - ); - } - - public function testIdentify(): void - { - self::assertTrue( - $this->client->identify( - array( - "distinctId" => "user-id", - "properties" => array( - "loves_php" => false, - "type" => "consumer fork-curl test", - "birthday" => time(), - ), - ) - ) - ); - } - - public function testAlias(): void + protected function consumer(): string { - self::assertTrue( - $this->client->alias( - array( - "alias" => "alias-id", - "distinctId" => "user-id", - ) - ) - ); + return 'fork_curl'; } } diff --git a/test/ConsumerLibCurlTest.php b/test/ConsumerLibCurlTest.php index 8c47678..908530b 100644 --- a/test/ConsumerLibCurlTest.php +++ b/test/ConsumerLibCurlTest.php @@ -2,62 +2,10 @@ namespace PostHog\Test; -use PHPUnit\Framework\TestCase; -use PostHog\Client; - -class ConsumerLibCurlTest extends TestCase +class ConsumerLibCurlTest extends ConsumerTransportTestCase { - private $client; - - public function setUp(): void - { - date_default_timezone_set("UTC"); - $this->client = new Client( - "BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", - [ - "consumer" => "lib_curl", - "debug" => true, - ] - ); - } - - public function testCapture(): void - { - self::assertTrue( - $this->client->capture( - array( - "distinctId" => "lib-curl-capture", - "event" => "PHP Lib Curl'd\" Event", - ) - ) - ); - } - - public function testIdentify(): void - { - self::assertTrue( - $this->client->identify( - array( - "distinctId" => "lib-curl-identify", - "properties" => array( - "loves_php" => false, - "type" => "consumer lib-curl test", - "birthday" => time(), - ), - ) - ) - ); - } - - public function testAlias(): void + protected function consumer(): string { - self::assertTrue( - $this->client->alias( - array( - "alias" => "lib-curl-alias", - "distinctId" => "user-id", - ) - ) - ); + return 'lib_curl'; } } diff --git a/test/ConsumerSocketTest.php b/test/ConsumerSocketTest.php index 53ccc51..38479ce 100644 --- a/test/ConsumerSocketTest.php +++ b/test/ConsumerSocketTest.php @@ -2,174 +2,77 @@ namespace PostHog\Test; -use Exception; -use PHPUnit\Framework\TestCase; -use PostHog\Client; use RuntimeException; -class ConsumerSocketTest extends TestCase +class ConsumerSocketTest extends ConsumerTransportTestCase { - public function setUp(): void + protected function consumer(): string { - date_default_timezone_set("UTC"); - } - - public function testCapture(): void - { - $client = new Client( - "BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", - array( - "consumer" => "socket", - ) - ); - self::assertTrue( - $client->capture( - array( - "distinctId" => "some-user", - "event" => "Socket PHP Event", - ) - ) - ); - $client->__destruct(); - } - - public function testIdentify(): void - { - $client = new Client( - "BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", - array( - "consumer" => "socket", - ) - ); - self::assertTrue( - $client->identify( - array( - "distinctId" => "Calvin", - "properties" => array( - "loves_php" => false, - "birthday" => time(), - ), - ) - ) - ); - $client->__destruct(); + return 'socket'; } public function testShortTimeout(): void { - $client = new Client( - "BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", - array( - "timeout" => 0.01, - "consumer" => "socket", - ) - ); - - self::assertTrue( - $client->capture( - array( - "distinctId" => "some-user", - "event" => "Socket PHP Event", - ) - ) - ); - - self::assertTrue( - $client->identify( - array( - "distinctId" => "some-user", - "properties" => array(), - ) - ) - ); - - $client->__destruct(); + $client = $this->client(['timeout' => 0.01]); + self::assertTrue($client->capture(['distinctId' => 'some-user', 'event' => 'short timeout'])); + self::assertSame('short timeout', $this->flushEvent($client)['event']); } public function testBatchSizeOneConnectionErrorReturnsFalse(): void { - $client = new Client( - "x", - array( - "batch_size" => 1, - "consumer" => "socket", - "host" => "invalid.invalid", - "ssl" => false, - "timeout" => 0.01, - ) - ); - - self::assertFalse( - $client->capture( - array( - "distinctId" => "some-user", - "event" => "Socket PHP Event", - ) - ) - ); + $this->server->stop(); + $client = $this->client(['batch_size' => 1, 'debug' => false, 'timeout' => 0.01]); + self::assertFalse($client->capture(['distinctId' => 'some-user', 'event' => 'connection refused'])); } public function testProductionProblems(): void { - $client = new Client( - "x", - array( - "consumer" => "socket", - "error_handler" => function () { - throw new Exception("Was called"); - }, - ) - ); - - // Shouldn't error out without debug on. - $client->capture(array("user_id" => "some-user", "event" => "Production Problems")); - $client->__destruct(); - self::assertTrue(true); + $this->server->stop(); + $client = $this->client(['debug' => false, 'timeout' => 0.01]); + self::assertTrue($client->capture(['distinctId' => 'some-user', 'event' => 'connection refused'])); + self::assertFalse($client->flush()); + self::assertTrue($client->flush(), 'Non-retryable socket failures drop the failed batch'); } public function testLargeMessage(): void { - $options = array( - "debug" => true, - "consumer" => "socket", - ); - - $client = new Client("BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", $options); - - $big_property = ""; - - for ($i = 0; $i < 10000; ++$i) { - $big_property .= "a"; - } - - self::assertTrue( - $client->capture( - array( - "distinctId" => "some-user", - "event" => "Super Large PHP Event", - "properties" => array("big_property" => $big_property), - ) - ) - ); + $client = $this->client(); + $largeProperty = str_repeat('a', 10000); + self::assertTrue($client->capture([ + 'distinctId' => 'some-user', + 'event' => 'large event', + 'properties' => ['big_property' => $largeProperty], + ])); + self::assertSame($largeProperty, $this->flushEvent($client)['properties']['big_property']); + } - $client->__destruct(); + public function testHttpFailureDropsBatchAndLogsWhenDebugging(): void + { + $this->server->stop(); + $this->server = new LocalHttpServer([['status' => 400, 'body' => 'invalid payload']]); + global $errorMessages; + $errorMessages = []; + $client = $this->client(); + self::assertTrue($client->capture(['distinctId' => 'some-user', 'event' => 'rejected'])); + self::assertFalse($client->flush()); + self::assertTrue($client->flush()); + self::assertCount(1, $this->server->requests()); + self::assertSame(['[PostHog][Socket] invalid payload'], $errorMessages); } public function testConnectionError(): void { - $this->expectException('RuntimeException'); - $client = new Client( - "x", - array( - "consumer" => "socket", - "host" => "t.posthog.comcomcom", - "error_handler" => function ($errno, $errmsg) { - throw new RuntimeException($errmsg, $errno); - }, - ) - ); - - $client->capture(array("user_id" => "some-user", "event" => "Event")); - $client->__destruct(); + $this->server->stop(); + $client = $this->client([ + 'debug' => false, + 'timeout' => 0.01, + 'error_handler' => static function ($errno, $message): void { + throw new RuntimeException('socket connection failed', (int) $errno); + }, + ]); + self::assertTrue($client->capture(['distinctId' => 'some-user', 'event' => 'connection refused'])); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('socket connection failed'); + $client->flush(); } } diff --git a/test/ConsumerTransportTestCase.php b/test/ConsumerTransportTestCase.php new file mode 100644 index 0000000..c124cbb --- /dev/null +++ b/test/ConsumerTransportTestCase.php @@ -0,0 +1,102 @@ +server = new LocalHttpServer(); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + protected function client(array $options = []): Client + { + return new Client('test-key', array_merge([ + 'consumer' => $this->consumer(), + 'host' => $this->server->address(), + 'ssl' => false, + 'debug' => true, + 'maximum_backoff_duration' => 100, + ], $options), null, null, false); + } + + public static function compressionCases(): array + { + return ['plain JSON' => [false], 'gzip' => [true]]; + } + + #[DataProvider('compressionCases')] + public function testCapture(bool $compressed): void + { + $client = $this->client(['compress_request' => $compressed]); + self::assertTrue($client->capture([ + 'distinctId' => 'some-user', + 'event' => 'PHP "quoted" and apostrophe\' event', + 'properties' => ['text' => "line one\nline two café", 'enabled' => false], + ])); + $event = $this->flushEvent($client, $compressed); + self::assertSame('PHP "quoted" and apostrophe\' event', $event['event']); + self::assertSame('some-user', $event['distinct_id']); + self::assertSame("line one\nline two café", $event['properties']['text']); + self::assertFalse($event['properties']['enabled']); + } + + #[DataProvider('compressionCases')] + public function testIdentify(bool $compressed): void + { + $client = $this->client(['compress_request' => $compressed]); + self::assertTrue($client->identify([ + 'distinctId' => 'identified-user', + 'properties' => ['loves_php' => false, 'birthday' => 1704067200], + ])); + $event = $this->flushEvent($client, $compressed); + self::assertSame('$identify', $event['event']); + self::assertSame('identified-user', $event['distinct_id']); + self::assertFalse($event['properties']['loves_php']); + self::assertSame(1704067200, $event['properties']['birthday']); + } + + #[DataProvider('compressionCases')] + public function testAlias(bool $compressed): void + { + $client = $this->client(['compress_request' => $compressed]); + self::assertTrue($client->alias(['alias' => 'previous-id', 'distinctId' => 'user-id'])); + $event = $this->flushEvent($client, $compressed); + self::assertSame('$create_alias', $event['event']); + self::assertSame('previous-id', $event['properties']['alias']); + self::assertSame('user-id', $event['properties']['distinct_id']); + } + + protected function flushEvent(Client $client, bool $compressed = false): array + { + self::assertSame([], $this->server->requests()); + self::assertTrue($client->flush()); + $requests = $this->server->requests(); + self::assertCount(1, $requests); + self::assertSame('POST /batch/ HTTP/1.1', $requests[0]['requestLine']); + self::assertSame('application/json', $requests[0]['headers']['content-type']); + self::assertSame('posthog-php/' . PostHog::VERSION, $requests[0]['headers']['user-agent']); + self::assertSame($compressed ? 'gzip' : null, $requests[0]['headers']['content-encoding'] ?? null); + $payload = json_decode($requests[0]['body'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame('test-key', $payload['api_key']); + self::assertCount(1, $payload['batch']); + self::assertArrayNotHasKey('type', $payload['batch'][0]); + self::assertTrue($client->flush()); + self::assertCount(1, $this->server->requests(), 'A successful flush must drain the queue'); + return $payload['batch'][0]; + } +} diff --git a/test/EtagSupportTest.php b/test/EtagSupportTest.php index b9878ab..146f8bc 100644 --- a/test/EtagSupportTest.php +++ b/test/EtagSupportTest.php @@ -314,5 +314,8 @@ public function testProcessesErrorResponseWithoutFlagsKey(): void $this->client->loadFlags(); $this->assertCount(1, $this->client->featureFlags); $this->assertEquals('person-flag', $this->client->featureFlags[0]['key']); + $this->assertSame('"original-etag"', $this->client->getFlagsEtag()); + global $errorMessages; + $this->assertStringContainsString('Failed to load feature flags (HTTP 500)', implode("\n", $errorMessages)); } } diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index b3ff9be..2d4dec0 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -283,6 +283,7 @@ public function testErrorHandlerRespectsRuntimeSuppression(): void $this->assertTrue($result); $this->assertSame(1, $previousCalls); + $this->client->flush(); $this->assertNull($this->findBatchCall()); } finally { error_reporting($previousReporting); @@ -721,7 +722,7 @@ private function runStandaloneScript( $output = []; $exitCode = 0; - exec(PHP_BINARY . ' ' . escapeshellarg($scriptPath), $output, $exitCode); + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($scriptPath), $output, $exitCode); $this->assertSame($expectedExitCode, $exitCode, implode("\n", $output)); diff --git a/test/ExceptionPayloadBuilderTest.php b/test/ExceptionPayloadBuilderTest.php index 507f018..5e5b705 100644 --- a/test/ExceptionPayloadBuilderTest.php +++ b/test/ExceptionPayloadBuilderTest.php @@ -130,7 +130,7 @@ public function testChainedExceptionsProduceMultipleEntries(): void $this->assertEquals('InvalidArgumentException', $result[1]['type']); } - public function testReturnsEmptyArrayForInvalidInput(): void + public function testBuildExceptionListRejectsInvalidInput(): void { $this->expectException(\TypeError::class); ExceptionPayloadBuilder::buildExceptionList([]); @@ -143,9 +143,10 @@ public function testContextLinesAddedForInAppFrames(): void $result = ExceptionPayloadBuilder::buildExceptionList($e); $frames = $result[0]['stacktrace']['frames']; - // Any in-app frame whose source file is readable should have context_line - $testFrames = array_filter($frames, fn($f) => isset($f['context_line'])); - $this->assertNotEmpty($testFrames, 'At least one in-app frame should have context_line'); + $crashFrame = $frames[count($frames) - 1]; + $this->assertSame(__FILE__, $crashFrame['abs_path']); + $this->assertSame($e->getLine(), $crashFrame['lineno']); + $this->assertSame("throw new \\RuntimeException('context test');", trim($crashFrame['context_line'])); } public function testStacktraceUsesThrowableFileAndLineForMostRecentFrame(): void @@ -529,7 +530,7 @@ public function testCaptureExceptionFromString(): void $this->assertEquals('a plain string error', $props['$exception_list'][0]['value']); } - public function testCaptureExceptionReturnsFalseForInvalidInput(): void + public function testCaptureExceptionRejectsInvalidInput(): void { $this->expectException(\TypeError::class); $this->client->captureException([]); @@ -596,6 +597,13 @@ public function testPostHogFacadeCaptureException(): void { $result = PostHog::captureException(new \Exception('facade test'), 'facade-user'); $this->assertTrue($result); + $this->assertTrue(PostHog::flush()); + $call = $this->findBatchCall(); + $this->assertNotNull($call); + $event = json_decode($call['payload'], true)['batch'][0]; + $this->assertSame('$exception', $event['event']); + $this->assertSame('facade-user', $event['distinct_id']); + $this->assertSame('facade test', $event['properties']['$exception_list'][0]['value']); } // ------------------------------------------------------------------------- diff --git a/test/FeatureFlagErrorTest.php b/test/FeatureFlagErrorTest.php index 9eb0934..8cc160c 100644 --- a/test/FeatureFlagErrorTest.php +++ b/test/FeatureFlagErrorTest.php @@ -173,17 +173,13 @@ public function sendRequest( array $extraHeaders = [], array $requestOptions = [] ): \PostHog\HttpResponse { - if (!isset($this->calls)) { - $this->calls = []; - } - array_push($this->calls, array( - "path" => $path, - "payload" => $payload, - "extraHeaders" => $extraHeaders, - "requestOptions" => $requestOptions - )); - if (str_starts_with($path, "/flags/")) { + $this->calls[] = [ + 'path' => $path, + 'payload' => $payload, + 'extraHeaders' => $extraHeaders, + 'requestOptions' => $requestOptions, + ]; throw new \Exception("Network error"); } @@ -256,7 +252,7 @@ public function testTimeoutError() $this->client = new Client( self::FAKE_API_KEY, - ["debug" => true], + ["debug" => true, "feature_flag_request_max_retries" => 0], $this->http_client, null ); diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index a83f07f..62d28f0 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -103,6 +103,10 @@ public function testEvaluateFlagsReturnsSnapshotAndMakesOneFlagsRequest(): void $this->assertSame(1, $this->flagsRequestCount()); $this->assertContains('simple-test', $snapshot->getKeys()); $this->assertContains('multivariate-test', $snapshot->getKeys()); + $this->assertTrue($snapshot->isEnabled('simple-test')); + $this->assertFalse($snapshot->isEnabled('having_fun')); + $this->assertSame('variant-value', $snapshot->getFlag('multivariate-test')); + $this->assertSame(1, $this->flagsRequestCount()); } public function testNoFeatureFlagCalledEventsUntilAccess(): void @@ -164,6 +168,8 @@ public function testGetFlagPayloadDoesNotFireEvent(): void $this->assertSame(['key' => 'value'], $payload); $this->assertSame([], $this->batchRequests()); + $this->assertSame([], $snapshot->onlyAccessed()->getKeys()); + $this->assertSame(1, $this->flagsRequestCount()); } public function testUnknownKeyAccessRecordsFlagMissingError(): void @@ -476,8 +482,10 @@ public function testLocalEvaluationSkipsRemoteFlagsRequestWhenAllResolved(): voi personalApiKey: 'test-personal-key', localEvaluationResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, ); - PostHog::evaluateFlags('user-1', personProperties: ['region' => 'USA']); + $snapshot = PostHog::evaluateFlags('user-1', personProperties: ['region' => 'USA']); + $this->assertSame(['person-flag'], $snapshot->getKeys()); + $this->assertTrue($snapshot->isEnabled('person-flag')); $this->assertSame(0, $this->flagsRequestCount()); } diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index e567a88..54fb223 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -6,6 +6,7 @@ require_once 'test/error_log_mock.php'; use Exception; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\FeatureFlag; use PostHog\Client; @@ -137,11 +138,6 @@ public function testMatchPropertyEquals(): void "key" => null, ])); - self::expectException(InconclusiveMatchException::class); - FeatureFlag::matchProperty($prop, [ - "key2" => "value2", - ]); - $prop = [ "key" => "key", "value" => "value", @@ -184,6 +180,12 @@ public function testMatchPropertyEquals(): void ]); } + public function testDefaultExactOperatorWithMissingPropertyIsInconclusive(): void + { + self::expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty(['key' => 'key', 'value' => 'value'], ['key2' => 'value2']); + } + public function testMatchPropertyNotIn(): void { $prop = [ @@ -1051,28 +1053,6 @@ public function testMatchPropertyDateOperators(): void "key" => "2022-04-30", ])); - // can't be an invalid number or invalid string - self::expectException(Exception::class); - FeatureFlag::matchProperty($prop_a, [ - "key" => "abcdef", - ]); - self::expectException(InconclusiveMatchException::class); - FeatureFlag::matchProperty($prop_a, [ - "key" => "62802180000012345", - ]); - - // // invalid flag property - // const property_c = { key: 'key', value: 'abcd123', operator: 'is_date_before' } - $prop_c = [ - "key" => "key", - "value" => "abcd123", - "operator" => "is_date_before" - ]; - self::expectException(InconclusiveMatchException::class); - FeatureFlag::matchProperty($prop_c, [ - "key" => "2022-05-30", - ]); - // // Timezone $prop_d = [ "key" => "key", @@ -1096,6 +1076,25 @@ public function testMatchPropertyDateOperators(): void ])); } + public static function invalidAbsoluteDateCases(): array + { + return [ + 'invalid property' => ['2022-05-01', 'abcdef'], + 'oversized numeric date' => ['2022-05-01', '62802180000012345'], + 'invalid condition' => ['abcd123', '2022-05-30'], + ]; + } + + #[DataProvider('invalidAbsoluteDateCases')] + public function testMalformedAbsoluteDateThrows(string $condition, string $value): void + { + self::expectException(Exception::class); + self::expectExceptionMessage('Failed to parse time string'); + FeatureFlag::matchProperty([ + 'key' => 'key', 'value' => $condition, 'operator' => 'is_date_before', + ], ['key' => $value]); + } + public function testMatchPropertyRelativeDateOperators(): void { $this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () { @@ -1140,6 +1139,7 @@ public function testMatchPropertyRelativeDateOperators(): void FeatureFlag::matchProperty($prop_a, [ "key" => "abcdef", ]); + self::fail('Expected malformed property date to throw'); } catch (Exception $exception) { self::assertStringContainsString("Failed to parse time string (abcdef) at position 0 (a): The timezone could not be found in the database", $exception->getMessage()); } @@ -1175,16 +1175,18 @@ public function testMatchPropertyRelativeDateOperators(): void FeatureFlag::matchProperty($prop_c, [ "key" => "2022-05-30", ]); + self::fail('Expected invalid condition date to be inconclusive'); } catch (InconclusiveMatchException $exception) { self::assertStringContainsString("The date provided 1234 must be a string or date object", $exception->getMessage()); } try { - FeatureFlag::matchProperty($prop_c, [ + FeatureFlag::matchProperty($prop_a, [ "key" => 1, ]); + self::fail('Expected invalid property date to be inconclusive'); } catch (InconclusiveMatchException $exception) { - self::assertStringContainsString("The date provided 1234 must be a string or date object", $exception->getMessage()); + self::assertStringContainsString("The date provided 1 must be a string or date object", $exception->getMessage()); } // # Try all possible relative dates @@ -1705,6 +1707,10 @@ public function testMixedTargetingLocalEvaluation(string $flagKey, array $opts, $opts['group_properties'] ?? [] ); $this->assertSame($expected, $result); + $this->assertSame( + ['/flags/definitions?send_cohorts&token=random_key'], + array_column($this->http_client->calls, 'path') + ); } public static function mixedTargetingProvider(): array @@ -1747,22 +1753,26 @@ public static function mixedTargetingProvider(): array public function testMixedTargetingRolloutBucketing() { + $definitions = MockedResponses::LOCAL_EVALUATION_GROUP_ROLLOUT_REQUEST; + $definitions['flags'][0]['filters']['groups'][0]['rollout_percentage'] = 50; $this->http_client = new MockedHttpClient( host: "app.posthog.com", - flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_GROUP_ROLLOUT_REQUEST + flagEndpointResponse: $definitions ); $this->client = new Client(self::FAKE_API_KEY, ["debug" => true], $this->http_client, "test"); PostHog::init(null, null, $this->client); - // With rollout 100% and a group passed, the group condition resolves locally — - // the matcher must hash on the group key, not the distinct_id. + // SHA-1 rollout fractions: person-a = 0.48159, person-b = 0.52761. $this->assertTrue(PostHog::getFeatureFlag( - 'rollout-flag', - 'any-distinct-id', - ["company" => "acme"], - [], - ["company" => []] + 'rollout-flag', 'person-b', ['company' => 'person-a'], [], ['company' => []] )); + $this->assertFalse(PostHog::getFeatureFlag( + 'rollout-flag', 'person-a', ['company' => 'person-b'], [], ['company' => []] + )); + $this->assertSame( + ['/flags/definitions?send_cohorts&token=random_key'], + array_column($this->http_client->calls, 'path') + ); } public function testFlagComplexDefinition() @@ -1807,7 +1817,11 @@ public function testFlagFallbackToDecide() public function testFlagFallbackToDecideWithFalseFlag() { - $this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: MockedResponses::FALLBACK_TO_FLAGS_REQUEST); + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::FALLBACK_TO_FLAGS_REQUEST, + flagsEndpointResponse: ['featureFlags' => ['false-flag' => false]] + ); $this->client = new Client( self::FAKE_API_KEY, [ @@ -1818,24 +1832,23 @@ public function testFlagFallbackToDecideWithFalseFlag() ); PostHog::init(null, null, $this->client); - $this->assertEquals(PostHog::getFeatureFlag('unknown-flag???', 'some-distinct'), null); - $this->assertEquals(PostHog::getFeatureFlag('false-flag', 'some-distinct'), null); + $this->assertNull(PostHog::getFeatureFlag('unknown-flag???', 'some-distinct')); + $this->assertFalse(PostHog::getFeatureFlag('false-flag', 'some-distinct')); + $this->assertSame( + ['/flags/definitions?send_cohorts&token=random_key', '/flags/?v=2', '/flags/?v=2'], + array_column($this->http_client->calls, 'path') + ); $this->checkEmptyErrorLogs(); } public function testFeatureFlagDefaultsComeIntoPlayOnlyWhenDecideErrorsOut() { - $this->client = new Client( - self::FAKE_API_KEY, - [ - "debug" => true, - ], - null, - null - ); + $this->http_client = new MockedHttpClient('unused', flagsEndpointResponseCode: 500); + $this->client = new Client(self::FAKE_API_KEY, [], $this->http_client); PostHog::init(null, null, $this->client); - $this->assertEquals(PostHog::getFeatureFlag('simple-flag', 'distinct-id'), null); + $this->assertNull(PostHog::getFeatureFlag('simple-flag', 'distinct-id')); + $this->assertSame(['/flags/?v=2'], array_column($this->http_client->calls, 'path')); } @@ -1909,8 +1922,11 @@ public function testGetAllFlagsWithNoFallback() $flags = PostHog::getAllFlags('distinct-id'); - $this->assertEquals($flags["variant-1"], true); - $this->assertEquals($flags["variant-2"], false); + $this->assertSame(['variant-1' => true, 'variant-2' => false], $flags); + $this->assertSame( + ['/flags/definitions?send_cohorts&token=random_key'], + array_column($this->http_client->calls, 'path') + ); } public function testLoadFeatureFlags() @@ -2095,49 +2111,14 @@ public function testFeatureFlagsDontFallbackToDecideWhenOnlyLocalEvaluationIsTru ); PostHog::init(null, null, $this->client); - # beta-feature should fallback to decide because property type is unknown, - # but doesn't because only_evaluate_locally is true - $this->assertEquals(PostHog::getFeatureFlag( - 'beta-feature', - 'some-distinct-id', - array(), - array(), - array(), - true, - false - ), null); - - $this->assertEquals(PostHog::isFeatureEnabled( - 'beta-feature', - 'some-distinct-id', - array(), - array(), - array(), - true, - false - ), null); - - # beta-feature2 should fallback to decide because region property not given with call - # but doesn't because only_evaluate_locally is true - $this->assertEquals(PostHog::getFeatureFlag( - 'beta-feature2', - 'some-distinct-id', - array(), - array(), - array(), - true, - false - ), null); - - $this->assertEquals(PostHog::isFeatureEnabled( - 'beta-feature2', - 'some-distinct-id', - array(), - array(), - array(), - true, - false - ), false); + foreach (['feature-1', 'feature-2', 'undefined-flag'] as $key) { + $this->assertNull(PostHog::getFeatureFlag($key, 'some-distinct-id', [], [], [], true, false)); + $this->assertNull(PostHog::isFeatureEnabled($key, 'some-distinct-id', [], [], [], true, false)); + } + $this->assertSame( + ['/flags/definitions?send_cohorts&token=random_key'], + array_column($this->http_client->calls, 'path') + ); } public function testComputingInactiveFlagLocally() @@ -2372,8 +2353,8 @@ public function testFlagWithVariantOverrides() ); PostHog::init(null, null, $this->client); - $this->assertEquals(PostHog::getFeatureFlag('beta-feature', 'test_id', [], ["email" => "test@posthog.com"]), "second-variant"); - $this->assertEquals(PostHog::getFeatureFlag('beta-feature', 'example_id'), "first-variant"); + $this->assertSame("second-variant", PostHog::getFeatureFlag('beta-feature', 'test_id', [], ["email" => "test@posthog.com"])); + $this->assertSame("first-variant", PostHog::getFeatureFlag('beta-feature', 'example_id')); } public function testFlagWithClashingVariantOverrides() @@ -2389,9 +2370,9 @@ public function testFlagWithClashingVariantOverrides() ); PostHog::init(null, null, $this->client); - $this->assertEquals(PostHog::getFeatureFlag('beta-feature', 'test_id', [], ["email" => "test@posthog.com"]), "second-variant"); - $this->assertEquals(PostHog::getFeatureFlag('beta-feature', 'example_id', [], ["email" => "test@posthog.com"]), "second-variant"); - $this->assertEquals(PostHog::getFeatureFlag('beta-feature', 'example_id'), "first-variant"); + $this->assertSame("second-variant", PostHog::getFeatureFlag('beta-feature', 'test_id', [], ["email" => "test@posthog.com"])); + $this->assertSame("second-variant", PostHog::getFeatureFlag('beta-feature', 'example_id', [], ["email" => "test@posthog.com"])); + $this->assertSame("first-variant", PostHog::getFeatureFlag('beta-feature', 'example_id')); } public function testFlagWithInvalidVariantOverrides() @@ -2407,8 +2388,8 @@ public function testFlagWithInvalidVariantOverrides() ); PostHog::init(null, null, $this->client); - $this->assertEquals(PostHog::getFeatureFlag('beta-feature', 'test_id', [], ["email" => "test@posthog.com"]), "third-variant"); - $this->assertEquals(PostHog::getFeatureFlag('beta-feature', 'example_id'), "second-variant"); + $this->assertSame("third-variant", PostHog::getFeatureFlag('beta-feature', 'test_id', [], ["email" => "test@posthog.com"])); + $this->assertSame("second-variant", PostHog::getFeatureFlag('beta-feature', 'example_id')); } public function testConditionsEvaluatedInOrder() @@ -2427,7 +2408,7 @@ public function testConditionsEvaluatedInOrder() // VIP users now match the first condition (100% rollout) instead of their specific variant override // because conditions are evaluated in order $result = PostHog::getFeatureFlag('test-flag', 'vip_user', [], ["email" => "user@vip.com"]); - $this->assertTrue(in_array($result, ['control', 'test'])); // Should get one of the regular variants, not vip-variant + $this->assertTrue(in_array($result, ['control', 'test'], true)); // Should get one of the regular variants, not vip-variant } public function testEventCalled() @@ -3469,7 +3450,7 @@ public function testFlagConsistency() ]; foreach (range(0, 999) as $number) { $testResult = PostHog::getFeatureFlag('simple-flag', sprintf('distinct_id_%s', $number)); - $this->assertEquals($testResult, $result[$number]); + $this->assertSame($result[$number], $testResult); } } @@ -4491,7 +4472,7 @@ public function testMultivariateFlagConsistency() ]; foreach (range(0, 999) as $number) { $testResult = PostHog::getFeatureFlag('multivariate-flag', sprintf('distinct_id_%s', $number)); - $this->assertEquals($testResult, $result[$number]); + $this->assertSame($result[$number], $testResult); } } @@ -5131,6 +5112,13 @@ public function testMatchPropertySemverComparisonOrder(): void // Patch version comparison self::assertTrue(FeatureFlag::matchProperty($prop_gt, ["version" => "1.0.1"])); self::assertTrue(FeatureFlag::matchProperty($prop_gt, ["version" => "1.0.10"])); + + foreach ([['2.0.0', '10.0.0'], ['1.2.0', '1.10.0'], ['1.0.2', '1.0.10']] as [$lower, $higher]) { + $prop = ['key' => 'version', 'value' => $lower, 'operator' => 'semver_gt']; + self::assertTrue(FeatureFlag::matchProperty($prop, ['version' => $higher])); + $prop['value'] = $higher; + self::assertFalse(FeatureFlag::matchProperty($prop, ['version' => $lower])); + } } public function testMatchPropertySemverZeroVersions(): void diff --git a/test/FeatureFlagTest.php b/test/FeatureFlagTest.php index 04cd59e..149bc83 100644 --- a/test/FeatureFlagTest.php +++ b/test/FeatureFlagTest.php @@ -593,7 +593,7 @@ public function testFlagCalledEventStaysFullWithoutBothMinimalSignals(bool $gate public function testGetFeatureFlagDefault($response) { $this->setUp($response); - $this->assertEquals(PostHog::getFeatureFlag('blah', 'user-id'), null); + $this->assertNull(PostHog::getFeatureFlag('blah', 'user-id')); $this->checkEmptyErrorLogs(); } @@ -637,7 +637,10 @@ public function testGetFeatureFlagGroups($response) public function testfetchFeatureVariants($response) { $this->setUp($response); - $this->assertIsArray(PostHog::fetchFeatureVariants('user-id')); + $flags = PostHog::fetchFeatureVariants('user-id'); + $this->assertTrue($flags['simple-test']); + $this->assertFalse($flags['having_fun']); + $this->assertSame('variant-value', $flags['multivariate-test']); } /** diff --git a/test/FlagDefinitionCacheProviderTest.php b/test/FlagDefinitionCacheProviderTest.php index 1ef5742..944d23c 100644 --- a/test/FlagDefinitionCacheProviderTest.php +++ b/test/FlagDefinitionCacheProviderTest.php @@ -289,18 +289,21 @@ public function testCamelCaseGroupTypeMappingIsAcceptedFromCache(): void public function testMinimalFlagCalledGatePersistsThroughProviderCache(): void { - // Simulates a restart: a fresh client reads definitions (including the - // minimal_flag_called_events gate) from the shared cache instead of the API and still - // minimizes $feature_flag_called events. $provider = new MockFlagDefinitionCacheProvider(); - $provider->shouldFetch = false; $data = $this->sampleFlagDefinitionData(); $data['flags'][0]['has_experiment'] = false; $data['minimal_flag_called_events'] = true; - $provider->cachedData = $data; - $httpClient = new MockedHttpClient(host: "app.posthog.com"); + $apiHttp = new MockedHttpClient(host: 'app.posthog.com', flagEndpointResponse: $data); + $originalClient = $this->createClient($provider, $apiHttp); + $this->assertTrue($provider->storedData['minimal_flag_called_events']); + $this->assertSame(1, $provider->onReceivedCallCount); + $originalClient->shutdown(); + $provider->shouldFetch = false; + $provider->cachedData = $provider->storedData; + $httpClient = new MockedHttpClient(host: "app.posthog.com"); $client = $this->createClient($provider, $httpClient); + $this->assertSame([], $httpClient->calls ?? []); $this->assertTrue($client->getFeatureFlag('beta-ui', 'user-1')); $client->flush(); diff --git a/test/FlagDependencyIntegrationTest.php b/test/FlagDependencyIntegrationTest.php index 1290edd..cdf3730 100644 --- a/test/FlagDependencyIntegrationTest.php +++ b/test/FlagDependencyIntegrationTest.php @@ -10,7 +10,7 @@ class FlagDependencyIntegrationTest extends TestCase public function testClientIntegrationWithFlagDependencies(): void { // Create a client with mock flags that have dependencies - $client = new Client("fake-api-key", [], null, null, false); + $client = new Client("fake-api-key", [], new MockedHttpClient('unused'), null, false); // Set up flags manually (simulating what would come from the API) $client->featureFlags = [ [ diff --git a/test/FlagDependencyTest.php b/test/FlagDependencyTest.php index 538d3d8..e95ad5d 100644 --- a/test/FlagDependencyTest.php +++ b/test/FlagDependencyTest.php @@ -430,7 +430,7 @@ public function testProductionStyleMultivariateDependencyChain(): void { // Test production-style multivariate dependency chain: // multivariate-root-flag -> multivariate-intermediate-flag -> multivariate-leaf-flag - $client = new Client("fake-api-key", [], null, "fake-personal-api-key", false); + $client = new Client("fake-api-key", [], new MockedHttpClient('unused'), "fake-personal-api-key", false); $client->featureFlags = [ // Leaf flag: multivariate with fruit variants [ @@ -633,9 +633,9 @@ public function testProductionStyleMultivariateDependencyChain(): void [], true ); - $this->assertEquals("pineapple", $leafResult); - $this->assertEquals("blue", $intermediateResult); - $this->assertEquals("breaking-bad", $rootResult); + $this->assertSame("pineapple", $leafResult); + $this->assertSame("blue", $intermediateResult); + $this->assertSame("breaking-bad", $rootResult); // Test successful mango -> red -> the-wire chain $mangoLeafResult = $client->getFeatureFlag( @@ -662,9 +662,9 @@ public function testProductionStyleMultivariateDependencyChain(): void [], true ); - $this->assertEquals("mango", $mangoLeafResult); - $this->assertEquals("red", $mangoIntermediateResult); - $this->assertEquals("the-wire", $mangoRootResult); + $this->assertSame("mango", $mangoLeafResult); + $this->assertSame("red", $mangoIntermediateResult); + $this->assertSame("the-wire", $mangoRootResult); // Test broken chain - user without matching email gets default/false results $unknownLeafResult = $client->getFeatureFlag( @@ -691,9 +691,9 @@ public function testProductionStyleMultivariateDependencyChain(): void [], true ); - $this->assertEquals(false, $unknownLeafResult); // No matching email -> null variant -> false - $this->assertEquals(false, $unknownIntermediateResult); // Dependency not satisfied - $this->assertEquals(false, $unknownRootResult); // Chain broken + $this->assertFalse($unknownLeafResult); + $this->assertFalse($unknownIntermediateResult); + $this->assertFalse($unknownRootResult); } public function testMultiLevelMultivariateDependencyChain(): void @@ -793,7 +793,7 @@ public function testMultiLevelMultivariateDependencyChain(): void $evaluationCache ); // Since email doesn't match, it should fall back to the second condition which has variant "control" - $this->assertEquals("control", $leafResult); + $this->assertSame("control", $leafResult); // Test 2: Intermediate flag should evaluate to "blue" when dependency is satisfied and variant_type is "blue" $evaluationCache = []; // Reset cache @@ -806,7 +806,7 @@ public function testMultiLevelMultivariateDependencyChain(): void $flagsByKey, $evaluationCache ); - $this->assertEquals("blue", $intermediateResult); + $this->assertSame("blue", $intermediateResult); // Test 3: Intermediate flag should evaluate to false when leaf dependency fails $evaluationCache = []; // Reset cache diff --git a/test/HttpClientTest.php b/test/HttpClientTest.php index a6361f2..43c37b1 100644 --- a/test/HttpClientTest.php +++ b/test/HttpClientTest.php @@ -2,6 +2,7 @@ // phpcs:ignoreFile namespace PostHog\Test; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\HttpClient; @@ -16,6 +17,14 @@ public function parseRetryAfter(array $headers): ?int class HttpClientTest extends TestCase { + public function testUnknownMockRouteCannotFallThroughToNetwork(): void + { + $client = new MockedHttpClient('unused.invalid'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Unexpected HTTP request in test: /unexpected'); + $client->sendRequest('/unexpected', null); + } + public function testMaskTokensInUrl(): void { $httpClient = new HttpClient("app.posthog.com"); @@ -46,6 +55,98 @@ public function testMaskTokensInUrl(): void $this->assertEquals('https://example.com/api/flags?token=&other=value', $result); } + public static function retryAfterCases(): array + { + return [ + 'absent' => [['Content-Type: application/json'], null], + 'empty' => [['Retry-After: '], null], + 'malformed' => [['Retry-After: not-a-date'], null], + 'past date' => [['Retry-After: Sat, 01 Jan 2000 00:00:00 GMT'], 0], + 'case insensitive' => [['retry-after: 2'], 2000], + ]; + } + + #[DataProvider('retryAfterCases')] + public function testRetryAfterEdgeCases(array $headers, ?int $expected): void + { + $this->assertSame($expected, (new RetryAfterHttpClient('unused'))->parseRetryAfter($headers)); + } + + public static function httpStatusCases(): array + { + return [ + 'success' => [200, true, 1], + 'bad request' => [400, true, 1], + 'unauthorized' => [401, true, 1], + 'forbidden' => [403, true, 1], + 'too large' => [413, true, 1], + 'request timeout' => [408, true, 2], + 'rate limited' => [429, true, 2], + 'server error' => [500, true, 2], + 'unavailable' => [503, true, 2], + 'retries disabled' => [503, false, 1], + ]; + } + + #[DataProvider('httpStatusCases')] + public function testHttpResponsesAndRetryPolicy(int $status, bool $retry, int $attempts): void + { + $server = new LocalHttpServer([ + ['status' => $status, 'body' => 'first response', 'headers' => ['Retry-After' => '0']], + ['status' => 200, 'body' => 'second response'], + ]); + try { + global $errorMessages; + $errorMessages = []; + $client = new HttpClient($server->address(), useSsl: false, maximumBackoffDuration: 201, debug: true); + $response = $client->sendRequest('/batch/', '{"batch":[]}', ['X-Test: preserved'], [ + 'shouldRetry' => $retry, 'timeout' => 5000, + ]); + $this->assertSame($status === 200 ? [] : ['[PostHog][HttpClient] ' . $status], $errorMessages); + $this->assertSame($attempts === 2 ? 200 : $status, $response->getResponseCode()); + $this->assertSame($attempts === 2 ? 'second response' : 'first response', $response->getResponse()); + $this->assertSame(0, $response->getCurlErrno()); + $requests = $server->requests(); + $this->assertCount($attempts, $requests); + $this->assertSame('POST /batch/ HTTP/1.1', $requests[0]['requestLine']); + $this->assertSame('{"batch":[]}', $requests[0]['body']); + $this->assertSame('preserved', $requests[0]['headers']['x-test']); + if ($attempts === 2) { + $this->assertSame($requests[0], $requests[1]); + } + } finally { + $server->stop(); + } + } + + public function testConditionalGetPreservesEtagAndSeparatesResponseHeaders(): void + { + $server = new LocalHttpServer([ + ['body' => '{"flags":[]}', 'headers' => ['eTaG' => '"revision-1"']], + ['status' => 304, 'body' => '', 'headers' => ['ETag' => '"revision-1"']], + ]); + try { + $client = new HttpClient($server->address(), useSsl: false); + $first = $client->sendRequest('/flags/definitions', null, [], ['includeEtag' => true]); + $this->assertSame(200, $first->getResponseCode()); + $this->assertSame('{"flags":[]}', $first->getResponse()); + $this->assertSame('"revision-1"', $first->getEtag()); + $second = $client->sendRequest('/flags/definitions', null, [ + 'If-None-Match: ' . $first->getEtag(), + ], ['includeEtag' => true]); + $this->assertSame(304, $second->getResponseCode()); + $this->assertSame('', $second->getResponse()); + $this->assertSame('"revision-1"', $second->getEtag()); + $requests = $server->requests(); + $this->assertCount(2, $requests); + $this->assertSame('GET /flags/definitions HTTP/1.1', $requests[0]['requestLine']); + $this->assertSame('', $requests[0]['body']); + $this->assertSame('"revision-1"', $requests[1]['headers']['if-none-match']); + } finally { + $server->stop(); + } + } + public function testRetryAfterMillisecondsParsesSeconds(): void { $httpClient = new RetryAfterHttpClient("app.posthog.com"); @@ -57,12 +158,13 @@ public function testRetryAfterMillisecondsParsesSeconds(): void public function testRetryAfterMillisecondsParsesHttpDate(): void { $httpClient = new RetryAfterHttpClient("app.posthog.com"); - $retryAt = gmdate('D, d M Y H:i:s \G\M\T', time() + 2); + $before = time(); + $retryAt = gmdate('D, d M Y H:i:s \G\M\T', $before + 3600); $retryAfterMs = $httpClient->parseRetryAfter(['Retry-After: ' . $retryAt]); $this->assertNotNull($retryAfterMs); - $this->assertGreaterThanOrEqual(0, $retryAfterMs); - $this->assertLessThanOrEqual(2000, $retryAfterMs); + $this->assertGreaterThanOrEqual(($before + 3600 - time()) * 1000, $retryAfterMs); + $this->assertLessThanOrEqual(3600000, $retryAfterMs); } } diff --git a/test/MockedHttpClient.php b/test/MockedHttpClient.php index 0be72bb..5fd1747 100644 --- a/test/MockedHttpClient.php +++ b/test/MockedHttpClient.php @@ -165,6 +165,6 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders ); } - return parent::sendRequest($path, $payload, $extraHeaders, $requestOptions); + throw new \LogicException('Unexpected HTTP request in test: ' . $path); } } diff --git a/test/MultivariateIntegrationTest.php b/test/MultivariateIntegrationTest.php index c667ddf..9207055 100644 --- a/test/MultivariateIntegrationTest.php +++ b/test/MultivariateIntegrationTest.php @@ -10,7 +10,7 @@ class MultivariateIntegrationTest extends TestCase public function testMultivariateFlagDependencies(): void { // Create a client with mock multivariate flags that have dependencies - $client = new Client("fake-api-key", [], null, null, false); + $client = new Client("fake-api-key", [], new MockedHttpClient('unused'), null, false); // Leaf flag: multivariate with consistent hashing $leafFlag = [ @@ -97,9 +97,10 @@ public function testMultivariateFlagDependencies(): void true // only_evaluate_locally ); + $this->assertContains($leafResult, ['variant-a', 'variant-b', 'variant-c']); if ($leafResult === "variant-a") { // When leaf flag is variant-a, dependent should be "special-variant" - $this->assertEquals("special-variant", $dependentResult); + $this->assertSame("special-variant", $dependentResult); $foundVariantA = true; } else { // When leaf flag is NOT variant-a, dependent should be false @@ -120,7 +121,7 @@ public function testMultivariateFlagDependencies(): void public function testBooleanFlagDependencyOnMultivariate(): void { // Test a boolean flag that depends on any variant of a multivariate flag - $client = new Client("fake-api-key", [], null, null, false); + $client = new Client("fake-api-key", [], new MockedHttpClient('unused'), null, false); $multivariateFlag = [ "id" => 1, diff --git a/test/PostHogTest.php b/test/PostHogTest.php index 48ff32e..248a2b3 100644 --- a/test/PostHogTest.php +++ b/test/PostHogTest.php @@ -237,17 +237,17 @@ public static function facadeNoOpBeforeInitCases(): array public function testInitWithParamApiKey(): void { - $this->expectNotToPerformAssertions(); - - PostHog::init("BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", array("debug" => true)); + PostHog::init('test-project-key', ['debug' => true]); + $this->assertSame('test-project-key', $this->readPrivate(PostHog::getClient(), 'apiKey')); + $this->assertNotInstanceOf(NoOp::class, $this->getConsumer(PostHog::getClient())); } public function testInitWithEnvApiKey(): void { - $this->expectNotToPerformAssertions(); - - $this->withEnvApiKey("BrpS4SctoaCCsyjlnlun3OzyNJAafdlv__jUWaaJWXg", function () { - PostHog::init(null, array("debug" => true)); + $this->withEnvApiKey('test-environment-key', function () { + PostHog::init(null, ['debug' => true]); + $this->assertSame('test-environment-key', $this->readPrivate(PostHog::getClient(), 'apiKey')); + $this->assertNotInstanceOf(NoOp::class, $this->getConsumer(PostHog::getClient())); }); } @@ -1572,54 +1572,17 @@ public function testIdentifyAndAliasTimestampsAreFormattedInUtc(): void public function testTimestamps(): void { - self::assertTrue( - PostHog::capture( - array( - "distinctId" => "user-id", - "event" => "integer-timestamp", - "timestamp" => (int) mktime(0, 0, 0, date('n'), 1, date('Y')), - ) - ) - ); - - self::assertTrue( - PostHog::capture( - array( - "distinctId" => "user-id", - "event" => "string-integer-timestamp", - "timestamp" => (string) mktime(0, 0, 0, date('n'), 1, date('Y')), - ) - ) - ); - - self::assertTrue( - PostHog::capture( - array( - "distinctId" => "user-id", - "event" => "iso8630-timestamp", - "timestamp" => date(DATE_ATOM, mktime(0, 0, 0, date('n'), 1, date('Y'))), - ) - ) - ); - - self::assertTrue( - PostHog::capture( - array( - "distinctId" => "user-id", - "event" => "iso8601-timestamp", - "timestamp" => date(DATE_ATOM, mktime(0, 0, 0, date('n'), 1, date('Y'))), - ) - ) - ); - - self::assertTrue( - PostHog::capture( - array( - "distinctId" => "user-id", - "event" => "strtotime-timestamp", - "timestamp" => strtotime('1 week ago'), - ) - ) + foreach ([1704067200, '1704067200', '2024-01-01T00:00:00+00:00', strtotime('2024-01-01 UTC')] as $timestamp) { + self::assertTrue(PostHog::capture([ + 'distinctId' => 'user-id', + 'event' => 'timestamp', + 'timestamp' => $timestamp, + ])); + } + self::assertTrue(PostHog::flush()); + self::assertSame( + array_fill(0, 4, '2024-01-01T00:00:00+00:00'), + array_column($this->firstBatchEvents(), 'timestamp') ); } @@ -1649,11 +1612,9 @@ public function testGroupIdentify(): void public function testGroupIdentifyValidation(): void { - try { - Posthog::groupIdentify(array()); - } catch (Exception $e) { - $this->assertEquals("PostHog::groupIdentify() expects a groupType", $e->getMessage()); - } + $this->expectException(Exception::class); + $this->expectExceptionMessage('PostHog::groupIdentify() expects a groupType'); + PostHog::groupIdentify([]); } public function testDefaultPropertiesGetAddedProperly(): void diff --git a/test/RequestContextTest.php b/test/RequestContextTest.php index 7b025f3..6bdb735 100644 --- a/test/RequestContextTest.php +++ b/test/RequestContextTest.php @@ -167,7 +167,9 @@ public function testWithContextRestoresAfterException(): void PostHog::withContext(['distinctId' => 'leaky-user'], function (): void { throw new \RuntimeException('boom'); }); - } catch (\RuntimeException) { + $this->fail('withContext must rethrow the callback exception'); + } catch (\RuntimeException $exception) { + $this->assertSame('boom', $exception->getMessage()); } $this->assertNull(PostHog::getContext()); @@ -324,14 +326,16 @@ public function testIntegrationCanSkipTracingHeadersAndStillWrapRequestMetadata( public function testEmptyAndNonStringHeaderValuesAreIgnored(): void { - $context = PostHog::contextFromHeaders([ - 'X-POSTHOG-DISTINCT-ID' => " \n\r\t ", - 'X-POSTHOG-SESSION-ID' => "\x00\x7F", - ]); + foreach ([" \n\r\t ", "\x00\x7F", null, 123, false, new \stdClass(), []] as $value) { + $context = PostHog::contextFromHeaders([ + 'X-POSTHOG-DISTINCT-ID' => $value, + 'X-POSTHOG-SESSION-ID' => $value, + ]); - $this->assertNull($context['distinctId']); - $this->assertNull($context['sessionId']); - $this->assertArrayNotHasKey('$session_id', $context['properties']); + $this->assertNull($context['distinctId']); + $this->assertNull($context['sessionId']); + $this->assertArrayNotHasKey('$session_id', $context['properties']); + } } public function testPhpServerNormalizedPostHogHeadersAreRecognized(): void