From 2751388c2368e02df1d9787d28f8496e2c37c1f7 Mon Sep 17 00:00:00 2001 From: Ben Poulson Date: Mon, 30 Jun 2025 23:10:14 +0100 Subject: [PATCH 1/4] Implement more unit tests --- Dockerfile | 42 +++ composer.json | 45 ++- docker/Dockerfile-7.4 | 30 -- docker/Dockerfile-8.0 | 30 -- docker/Dockerfile-8.1 | 30 -- docker/Dockerfile-8.2 | 30 -- docker/Dockerfile-8.3 | 30 -- docker/Dockerfile-8.4 | 30 -- src/Extension/ExtensionInterface.php | 47 +++ src/Extension/PerfbaseExtension.php | 47 +++ src/Http/ApiClient.php | 41 +-- src/Http/GuzzleHttpClient.php | 26 ++ src/Http/HttpClientInterface.php | 15 + src/Perfbase.php | 58 ++-- test_docker.sh | 8 +- tests/EnvironmentUtilsTest.php | 13 +- tests/Exception/PerfbaseExceptionTest.php | 130 +++++++ tests/Extension/ExtensionTest.php | 128 +++++++ tests/FeatureFlagsTest.php | 206 ++++++++++++ tests/Http/ApiClientTest.php | 194 +++++++++++ tests/Http/GuzzleHttpClientTest.php | 118 +++++++ tests/Integration/PerfbaseIntegrationTest.php | 263 +++++++++++++++ tests/PerfbaseTest.php | 316 ++++++++++++++++++ tests/Utils/ExtensionUtilsTest.php | 149 +++++++++ 24 files changed, 1778 insertions(+), 248 deletions(-) create mode 100644 Dockerfile delete mode 100644 docker/Dockerfile-7.4 delete mode 100644 docker/Dockerfile-8.0 delete mode 100644 docker/Dockerfile-8.1 delete mode 100644 docker/Dockerfile-8.2 delete mode 100644 docker/Dockerfile-8.3 delete mode 100644 docker/Dockerfile-8.4 create mode 100644 src/Extension/ExtensionInterface.php create mode 100644 src/Extension/PerfbaseExtension.php create mode 100644 src/Http/GuzzleHttpClient.php create mode 100644 src/Http/HttpClientInterface.php create mode 100644 tests/Exception/PerfbaseExceptionTest.php create mode 100644 tests/Extension/ExtensionTest.php create mode 100644 tests/FeatureFlagsTest.php create mode 100644 tests/Http/ApiClientTest.php create mode 100644 tests/Http/GuzzleHttpClientTest.php create mode 100644 tests/Integration/PerfbaseIntegrationTest.php create mode 100644 tests/PerfbaseTest.php create mode 100644 tests/Utils/ExtensionUtilsTest.php diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..196f3e8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +ARG PHP_VERSION=8.4 +FROM php:${PHP_VERSION}-fpm + +ENV PATH="/composer/vendor/bin:$PATH" +ENV COMPOSER_ALLOW_SUPERUSER=1 +ENV COMPOSER_HOME=/composer + +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + git \ + wget \ + unzip \ + libzip-dev \ + libonig-dev \ + libxml2-dev \ + && docker-php-ext-install pdo pdo_mysql zip mbstring bcmath \ + && apt-get clean + +# Set the safe directory for git +RUN git config --global --add safe.directory /var/www/html + +# Install Perfbase +RUN bash -c "$(curl -fsSL https://cdn.perfbase.com/install.sh)" + +# Set working directory +WORKDIR /app + +# Install PHP dependencies +COPY composer.json ./composer.json +RUN composer install --prefer-dist --no-progress --no-scripts + +# Copy project files to container +COPY . . + +# Add Composer's global bin directory to PATH +ENV PATH="/composer/vendor/bin:$PATH" + +# Default Entrypoint +ENTRYPOINT [] \ No newline at end of file diff --git a/composer.json b/composer.json index 01efe99..6ae3a83 100644 --- a/composer.json +++ b/composer.json @@ -1,29 +1,24 @@ { "name": "perfbase/php-sdk", "description": "An SDK for sending profiling data to Perfbase", + "keywords": [ + "Perfbase", + "php", + "profiling" + ], + "homepage": "https://github.com/perfbaseorg/php-sdk", + "support": { + "issues": "https://github.com/perfbaseorg/php-sdk/issues", + "source": "https://github.com/perfbaseorg/php-sdk" + }, "type": "library", "license": "Apache-2.0", - "autoload": { - "psr-4": { - "Perfbase\\SDK\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Perfbase\\SDK\\Tests\\": "tests/" - } - }, "authors": [ { "name": "Ben Poulson", "email": "ben.poulson@perfbase.com" } ], - "scripts": { - "lint": "composer run-script phpstan && composer run-script test", - "test": "phpunit", - "phpstan": "phpstan analyse --memory-limit=2G" - }, "require": { "php": ">=7.4 <8.5", "ext-curl": "*", @@ -33,5 +28,25 @@ "phpstan/phpstan": "^2.1", "mockery/mockery": "^1.6", "phpunit/phpunit": "^9" + }, + "autoload": { + "psr-4": { + "Perfbase\\SDK\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Perfbase\\SDK\\Tests\\": "tests/" + } + }, + "scripts": { + "lint": "composer run-script phpstan && composer run-script test", + "test": "phpunit", + "phpstan": "phpstan analyse --memory-limit=2G" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true } } diff --git a/docker/Dockerfile-7.4 b/docker/Dockerfile-7.4 deleted file mode 100644 index b1178c9..0000000 --- a/docker/Dockerfile-7.4 +++ /dev/null @@ -1,30 +0,0 @@ -FROM php:7.4-cli - -# Set environment variables -ENV COMPOSER_ALLOW_SUPERUSER=1 \ - COMPOSER_HOME=/composer - -# Update system and install necessary dependencies -RUN apt-get update && apt-get install -y \ - git \ - unzip \ - libzip-dev \ - && docker-php-ext-install zip \ - && apt-get clean - -# Copy Composer binary from the official Composer image -COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer - -# Set working directory -WORKDIR /app - -COPY composer.json ./composer.json - -# Install PHP dependencies -RUN composer install --prefer-dist --no-progress --no-scripts - -# Copy project files to container -COPY . . - -# Add Composer's global bin directory to PATH -ENV PATH="/composer/vendor/bin:$PATH" diff --git a/docker/Dockerfile-8.0 b/docker/Dockerfile-8.0 deleted file mode 100644 index c3bfa69..0000000 --- a/docker/Dockerfile-8.0 +++ /dev/null @@ -1,30 +0,0 @@ -FROM php:8.0-cli - -# Set environment variables -ENV COMPOSER_ALLOW_SUPERUSER=1 \ - COMPOSER_HOME=/composer - -# Update system and install necessary dependencies -RUN apt-get update && apt-get install -y \ - git \ - unzip \ - libzip-dev \ - && docker-php-ext-install zip \ - && apt-get clean - -# Copy Composer binary from the official Composer image -COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer - -# Set working directory -WORKDIR /app - -COPY composer.json ./composer.json - -# Install PHP dependencies -RUN composer install --prefer-dist --no-progress --no-scripts - -# Copy project files to container -COPY . . - -# Add Composer's global bin directory to PATH -ENV PATH="/composer/vendor/bin:$PATH" diff --git a/docker/Dockerfile-8.1 b/docker/Dockerfile-8.1 deleted file mode 100644 index 30d6f8c..0000000 --- a/docker/Dockerfile-8.1 +++ /dev/null @@ -1,30 +0,0 @@ -FROM php:8.1-cli - -# Set environment variables -ENV COMPOSER_ALLOW_SUPERUSER=1 \ - COMPOSER_HOME=/composer - -# Update system and install necessary dependencies -RUN apt-get update && apt-get install -y \ - git \ - unzip \ - libzip-dev \ - && docker-php-ext-install zip \ - && apt-get clean - -# Copy Composer binary from the official Composer image -COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer - -# Set working directory -WORKDIR /app - -COPY composer.json ./composer.json - -# Install PHP dependencies -RUN composer install --prefer-dist --no-progress --no-scripts - -# Copy project files to container -COPY . . - -# Add Composer's global bin directory to PATH -ENV PATH="/composer/vendor/bin:$PATH" diff --git a/docker/Dockerfile-8.2 b/docker/Dockerfile-8.2 deleted file mode 100644 index ffb148f..0000000 --- a/docker/Dockerfile-8.2 +++ /dev/null @@ -1,30 +0,0 @@ -FROM php:8.2-cli - -# Set environment variables -ENV COMPOSER_ALLOW_SUPERUSER=1 \ - COMPOSER_HOME=/composer - -# Update system and install necessary dependencies -RUN apt-get update && apt-get install -y \ - git \ - unzip \ - libzip-dev \ - && docker-php-ext-install zip \ - && apt-get clean - -# Copy Composer binary from the official Composer image -COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer - -# Set working directory -WORKDIR /app - -COPY composer.json ./composer.json - -# Install PHP dependencies -RUN composer install --prefer-dist --no-progress --no-scripts - -# Copy project files to container -COPY . . - -# Add Composer's global bin directory to PATH -ENV PATH="/composer/vendor/bin:$PATH" diff --git a/docker/Dockerfile-8.3 b/docker/Dockerfile-8.3 deleted file mode 100644 index 044439e..0000000 --- a/docker/Dockerfile-8.3 +++ /dev/null @@ -1,30 +0,0 @@ -FROM php:8.3-cli - -# Set environment variables -ENV COMPOSER_ALLOW_SUPERUSER=1 \ - COMPOSER_HOME=/composer - -# Update system and install necessary dependencies -RUN apt-get update && apt-get install -y \ - git \ - unzip \ - libzip-dev \ - && docker-php-ext-install zip \ - && apt-get clean - -# Copy Composer binary from the official Composer image -COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer - -# Set working directory -WORKDIR /app - -COPY composer.json ./composer.json - -# Install PHP dependencies -RUN composer install --prefer-dist --no-progress --no-scripts - -# Copy project files to container -COPY . . - -# Add Composer's global bin directory to PATH -ENV PATH="/composer/vendor/bin:$PATH" diff --git a/docker/Dockerfile-8.4 b/docker/Dockerfile-8.4 deleted file mode 100644 index e703106..0000000 --- a/docker/Dockerfile-8.4 +++ /dev/null @@ -1,30 +0,0 @@ -FROM php:8.4-cli - -# Set environment variables -ENV COMPOSER_ALLOW_SUPERUSER=1 \ - COMPOSER_HOME=/composer - -# Update system and install necessary dependencies -RUN apt-get update && apt-get install -y \ - git \ - unzip \ - libzip-dev \ - && docker-php-ext-install zip \ - && apt-get clean - -# Copy Composer binary from the official Composer image -COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer - -# Set working directory -WORKDIR /app - -COPY composer.json ./composer.json - -# Install PHP dependencies -RUN composer install --prefer-dist --no-progress --no-scripts - -# Copy project files to container -COPY . . - -# Add Composer's global bin directory to PATH -ENV PATH="/composer/vendor/bin:$PATH" diff --git a/src/Extension/ExtensionInterface.php b/src/Extension/ExtensionInterface.php new file mode 100644 index 0000000..22811a6 --- /dev/null +++ b/src/Extension/ExtensionInterface.php @@ -0,0 +1,47 @@ +config = $config; $this->defaultHeaders = [ @@ -38,18 +39,24 @@ public function __construct(Config $config) 'Connection' => 'keep-alive', ]; - /** @var array $httpClientConfig */ - $httpClientConfig = []; - $httpClientConfig['base_uri'] = $config->api_url; - $httpClientConfig['timeout'] = $config->timeout; + if ($httpClient !== null) { + $this->httpClient = $httpClient; + } else { + // Create default HTTP client + /** @var array $httpClientConfig */ + $httpClientConfig = []; + $httpClientConfig['base_uri'] = $config->api_url; + $httpClientConfig['timeout'] = $config->timeout; - // Set up proxy if configured - if ($config->proxy) { - $httpClientConfig['proxy'] = $config->proxy; - } + // Set up proxy if configured + if ($config->proxy) { + $httpClientConfig['proxy'] = $config->proxy; + } - // Set up the HTTP client - $this->httpClient = new GuzzleClient($httpClientConfig); + // Set up the HTTP client + $guzzleClient = new GuzzleClient($httpClientConfig); + $this->httpClient = new GuzzleHttpClient($guzzleClient); + } } /** @@ -78,11 +85,7 @@ private function submit(string $endpoint, string $perfData): void 'body' => $perfData, ]; - try { - $this->httpClient->post($endpoint, $options); - } catch (Throwable $e) { - // throw new PerfbaseException('HTTP Request failed: ' . $e->getMessage()); - } + $this->httpClient->post($endpoint, $options); } } diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php new file mode 100644 index 0000000..d75a3f7 --- /dev/null +++ b/src/Http/GuzzleHttpClient.php @@ -0,0 +1,26 @@ +client = $client; + } + + public function post(string $uri, array $options = []): void + { + try { + $this->client->post($uri, $options); + } catch (Throwable $e) { + // Silent failure as per original implementation + // Could be made configurable in the future + } + } +} \ No newline at end of file diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php new file mode 100644 index 0000000..48d7cd0 --- /dev/null +++ b/src/Http/HttpClientInterface.php @@ -0,0 +1,15 @@ + $options Request options including headers, body, etc. + * @return void + * @throws \Throwable If the request fails + */ + public function post(string $uri, array $options = []): void; +} \ No newline at end of file diff --git a/src/Perfbase.php b/src/Perfbase.php index 8c1afc4..5cb6e79 100644 --- a/src/Perfbase.php +++ b/src/Perfbase.php @@ -5,8 +5,9 @@ use Perfbase\SDK\Exception\PerfbaseExtensionException; use Perfbase\SDK\Exception\PerfbaseInvalidConfigException; use Perfbase\SDK\Exception\PerfbaseInvalidSpanException; +use Perfbase\SDK\Extension\ExtensionInterface; +use Perfbase\SDK\Extension\PerfbaseExtension; use Perfbase\SDK\Http\ApiClient; -use Perfbase\SDK\Utils\ExtensionUtils; /** * Main client class for the Perfbase SDK @@ -30,13 +31,10 @@ class Perfbase private const DEFAULT_SPAN_NAME = 'default'; /** - * Flag to indicate if the Perfbase extension is available - * -1 = not checked yet - * 0 = not available - * 1 = available - * @var int + * The extension interface for profiling operations + * @var ExtensionInterface */ - private static int $isAvailable = -1; + private ExtensionInterface $extension; /** * Manages the connection to the Perfbase API @@ -58,11 +56,17 @@ class Perfbase /** * Initialises the Perfbase SDK with the provided configuration + * @param Config $config + * @param ExtensionInterface|null $extension + * @param ApiClient|null $apiClient * @throws PerfbaseExtensionException * @throws PerfbaseInvalidConfigException */ - public function __construct(Config $config) + public function __construct(Config $config, ?ExtensionInterface $extension = null, ?ApiClient $apiClient = null) { + // Use provided extension or create default + $this->extension = $extension ?? new PerfbaseExtension(); + // Check if the Perfbase extension is available $this->ensureIsAvailable(); @@ -72,8 +76,8 @@ public function __construct(Config $config) // Set the configuration $this->config = $config; - // Create the API client - $this->apiClient = new ApiClient($config); + // Create or use provided API client + $this->apiClient = $apiClient ?? new ApiClient($config); } /** @@ -84,24 +88,11 @@ public function __construct(Config $config) */ private function ensureIsAvailable(): void { - if (!self::isAvailable()) { + if (!$this->extension->isAvailable()) { throw new PerfbaseExtensionException('Perfbase extension is not available.'); } } - /** - * Check if the extension is loaded and the required methods are available - * Result is cached to avoid multiple checks. - * @return bool - */ - public static function isAvailable(): bool - { - if (self::$isAvailable === -1) { - self::$isAvailable = ExtensionUtils::perfbaseExtensionLoaded() && ExtensionUtils::perfbaseMethodsAvailable() ? 1 : 0; - } - return self::$isAvailable === 1; - } - /** * Starts the profiling session * @@ -123,7 +114,7 @@ public function startTraceSpan(string $spanName): void // Set the state to active $this->activeSpans[$spanName] = true; - perfbase_enable($spanName, $this->config->flags); + $this->extension->enable($spanName, $this->config->flags); } /** @@ -175,7 +166,8 @@ public function stopTraceSpan(string $spanName): bool } // Set the state to complete - perfbase_disable($spanName); + unset($this->activeSpans[$spanName]); + $this->extension->disable($spanName); return true; } @@ -217,7 +209,7 @@ public function submitTrace(): void */ public function getTraceData(): string { - return perfbase_get_data(); + return $this->extension->getData(); } /** @@ -226,7 +218,8 @@ public function getTraceData(): string */ public function reset() { - perfbase_reset(); + $this->activeSpans = []; + $this->extension->reset(); } /** @@ -237,4 +230,13 @@ public function __destruct() $this->reset(); } + /** + * Check if the extension is available + * @return bool + */ + public function isExtensionAvailable(): bool + { + return $this->extension->isAvailable(); + } + } diff --git a/test_docker.sh b/test_docker.sh index 505d3c1..6cedf62 100755 --- a/test_docker.sh +++ b/test_docker.sh @@ -1,21 +1,21 @@ -#!/bin/bash -e +#!/bin/bash -ex # Define the PHP versions PHP_VERSIONS=("7.4" "8.0" "8.1" "8.2" "8.3" "8.4") # Loop through each PHP version for VERSION in "${PHP_VERSIONS[@]}"; do - DOCKERFILE="./docker/Dockerfile-${VERSION}" + DOCKERFILE="./Dockerfile" IMAGE_NAME="php-ci:${VERSION}" # Check if the Dockerfile exists if [ -f "$DOCKERFILE" ]; then echo "Building Docker image for PHP ${VERSION}..." - docker build -f "$DOCKERFILE" -t "$IMAGE_NAME" -q . + docker build -f "$DOCKERFILE" -t "$IMAGE_NAME" --build-arg PHP_VERSION="${VERSION}" -q . if [ $? -eq 0 ]; then echo "Successfully built ${IMAGE_NAME}. Running tests..." - docker run --rm "$IMAGE_NAME" './test.sh' + docker run --rm "$IMAGE_NAME" bash -c "composer run lint" if [ $? -eq 0 ]; then echo "Tests passed for PHP ${VERSION}." diff --git a/tests/EnvironmentUtilsTest.php b/tests/EnvironmentUtilsTest.php index 510155b..77e36bf 100644 --- a/tests/EnvironmentUtilsTest.php +++ b/tests/EnvironmentUtilsTest.php @@ -9,10 +9,19 @@ */ class EnvironmentUtilsTest extends BaseTest { + private array $originalServer; + + protected function setUp(): void + { + parent::setUp(); + $this->originalServer = $_SERVER; + } + protected function tearDown(): void { - // Restore $_SERVER array - $_SERVER = []; + // Restore original $_SERVER array + $_SERVER = $this->originalServer; + parent::tearDown(); } /** diff --git a/tests/Exception/PerfbaseExceptionTest.php b/tests/Exception/PerfbaseExceptionTest.php new file mode 100644 index 0000000..b3a27da --- /dev/null +++ b/tests/Exception/PerfbaseExceptionTest.php @@ -0,0 +1,130 @@ +assertInstanceOf(\Exception::class, $exception); + $this->assertEquals($message, $exception->getMessage()); + } + + /** + * @covers \Perfbase\SDK\Exception\PerfbaseException::__construct + */ + public function testPerfbaseExceptionWithEmptyMessage(): void + { + $exception = new PerfbaseException(); + + $this->assertInstanceOf(\Exception::class, $exception); + $this->assertEquals('', $exception->getMessage()); + } + + /** + * Test PerfbaseExtensionException + * @covers \Perfbase\SDK\Exception\PerfbaseExtensionException::__construct + */ + public function testPerfbaseExtensionException(): void + { + $message = 'Extension not loaded'; + $exception = new PerfbaseExtensionException($message); + + $this->assertInstanceOf(PerfbaseException::class, $exception); + $this->assertInstanceOf(\Exception::class, $exception); + $this->assertEquals($message, $exception->getMessage()); + } + + /** + * Test PerfbaseInvalidConfigException + * @covers \Perfbase\SDK\Exception\PerfbaseInvalidConfigException::__construct + */ + public function testPerfbaseInvalidConfigException(): void + { + $message = 'Invalid configuration'; + $exception = new PerfbaseInvalidConfigException($message); + + $this->assertInstanceOf(PerfbaseException::class, $exception); + $this->assertInstanceOf(\Exception::class, $exception); + $this->assertEquals($message, $exception->getMessage()); + } + + /** + * Test PerfbaseInvalidSpanException + * @covers \Perfbase\SDK\Exception\PerfbaseInvalidSpanException::__construct + */ + public function testPerfbaseInvalidSpanException(): void + { + $message = 'Invalid span name'; + $exception = new PerfbaseInvalidSpanException($message); + + $this->assertInstanceOf(PerfbaseException::class, $exception); + $this->assertInstanceOf(\Exception::class, $exception); + $this->assertEquals($message, $exception->getMessage()); + } + + /** + * Test exception inheritance hierarchy + * @covers \Perfbase\SDK\Exception\PerfbaseException + */ + public function testExceptionInheritanceHierarchy(): void + { + $baseException = new PerfbaseException('Base'); + $extensionException = new PerfbaseExtensionException('Extension'); + $configException = new PerfbaseInvalidConfigException('Config'); + $spanException = new PerfbaseInvalidSpanException('Span'); + + // All should inherit from PerfbaseException + $this->assertInstanceOf(PerfbaseException::class, $extensionException); + $this->assertInstanceOf(PerfbaseException::class, $configException); + $this->assertInstanceOf(PerfbaseException::class, $spanException); + + // All should inherit from base Exception + $this->assertInstanceOf(\Exception::class, $baseException); + $this->assertInstanceOf(\Exception::class, $extensionException); + $this->assertInstanceOf(\Exception::class, $configException); + $this->assertInstanceOf(\Exception::class, $spanException); + } + + /** + * Test that exceptions can be caught by their parent type + * @covers \Perfbase\SDK\Exception\PerfbaseException + */ + public function testExceptionCanBeCaughtByParentType(): void + { + $caughtAsPerfbaseException = false; + $caughtAsBaseException = false; + + try { + throw new PerfbaseInvalidSpanException('Test span error'); + } catch (PerfbaseException $e) { + $caughtAsPerfbaseException = true; + $this->assertEquals('Test span error', $e->getMessage()); + } + + try { + throw new PerfbaseExtensionException('Test extension error'); + } catch (\Exception $e) { + $caughtAsBaseException = true; + $this->assertEquals('Test extension error', $e->getMessage()); + } + + $this->assertTrue($caughtAsPerfbaseException); + $this->assertTrue($caughtAsBaseException); + } +} \ No newline at end of file diff --git a/tests/Extension/ExtensionTest.php b/tests/Extension/ExtensionTest.php new file mode 100644 index 0000000..15ac620 --- /dev/null +++ b/tests/Extension/ExtensionTest.php @@ -0,0 +1,128 @@ +markTestSkipped('Perfbase extension not loaded'); + } + + $extension = new PerfbaseExtension(); + $this->assertTrue($extension->isAvailable()); + } + + /** + * @covers ::isAvailable + */ + public function testIsAvailableCachesResult(): void + { + $extension = new PerfbaseExtension(); + + // Call twice to test caching + $result1 = $extension->isAvailable(); + $result2 = $extension->isAvailable(); + + $this->assertEquals($result1, $result2); + } + + /** + * @covers ::enable + */ + public function testEnable(): void + { + if (!ExtensionUtils::perfbaseExtensionLoaded()) { + $this->markTestSkipped('Perfbase extension not loaded'); + } + + $extension = new PerfbaseExtension(); + + // This should not throw an exception + $extension->enable('test-span', 0); + + // Clean up + $extension->disable('test-span'); + $extension->reset(); + + $this->assertTrue(true); // If we get here, no exception was thrown + } + + /** + * @covers ::disable + */ + public function testDisable(): void + { + if (!ExtensionUtils::perfbaseExtensionLoaded()) { + $this->markTestSkipped('Perfbase extension not loaded'); + } + + $extension = new PerfbaseExtension(); + + // This should not throw an exception + $extension->disable('test-span'); + + $this->assertTrue(true); // If we get here, no exception was thrown + } + + /** + * @covers ::getData + */ + public function testGetData(): void + { + if (!ExtensionUtils::perfbaseExtensionLoaded()) { + $this->markTestSkipped('Perfbase extension not loaded'); + } + + $extension = new PerfbaseExtension(); + + $data = $extension->getData(); + + $this->assertIsString($data); + } + + /** + * @covers ::reset + */ + public function testReset(): void + { + if (!ExtensionUtils::perfbaseExtensionLoaded()) { + $this->markTestSkipped('Perfbase extension not loaded'); + } + + $extension = new PerfbaseExtension(); + + // This should not throw an exception + $extension->reset(); + + $this->assertTrue(true); // If we get here, no exception was thrown + } + + /** + * @covers ::setAttribute + */ + public function testSetAttribute(): void + { + if (!ExtensionUtils::perfbaseExtensionLoaded()) { + $this->markTestSkipped('Perfbase extension not loaded'); + } + + $extension = new PerfbaseExtension(); + + // This should not throw an exception + $extension->setAttribute('test-key', 'test-value'); + + $this->assertTrue(true); // If we get here, no exception was thrown + } +} \ No newline at end of file diff --git a/tests/FeatureFlagsTest.php b/tests/FeatureFlagsTest.php new file mode 100644 index 0000000..aaf0eed --- /dev/null +++ b/tests/FeatureFlagsTest.php @@ -0,0 +1,206 @@ +assertEquals(0, $flag & ($flag - 1), "Flag $flag is not a power of 2"); + $this->assertGreaterThan(0, $flag, "Flag $flag should be greater than 0"); + } + } + + /** + * Test that DefaultFlags contains expected flags + * @covers \Perfbase\SDK\FeatureFlags + */ + public function testDefaultFlagsContainsExpectedFlags(): void + { + $expectedFlags = [ + FeatureFlags::UseCoarseClock, + FeatureFlags::TrackCpuTime, + FeatureFlags::TrackPdo, + FeatureFlags::TrackHttp, + FeatureFlags::TrackCaches, + FeatureFlags::TrackMongodb, + FeatureFlags::TrackElasticsearch, + FeatureFlags::TrackQueues, + FeatureFlags::TrackAwsSdk, + ]; + + foreach ($expectedFlags as $flag) { + $this->assertTrue( + (FeatureFlags::DefaultFlags & $flag) === $flag, + "DefaultFlags should contain flag $flag" + ); + } + } + + /** + * Test that DefaultFlags does not contain certain flags + * @covers \Perfbase\SDK\FeatureFlags + */ + public function testDefaultFlagsDoesNotContainCertainFlags(): void + { + $excludedFlags = [ + FeatureFlags::TrackExceptions, + FeatureFlags::TrackFileCompilation, + FeatureFlags::TrackMemoryAllocation, + FeatureFlags::TrackFileDefinitions, + FeatureFlags::TrackFileOperations, + ]; + + foreach ($excludedFlags as $flag) { + $this->assertFalse( + (FeatureFlags::DefaultFlags & $flag) === $flag, + "DefaultFlags should not contain flag $flag" + ); + } + } + + /** + * Test that AllFlags contains all individual flags + * @covers \Perfbase\SDK\FeatureFlags + */ + public function testAllFlagsContainsAllIndividualFlags(): void + { + $allFlags = [ + FeatureFlags::UseCoarseClock, + FeatureFlags::TrackExceptions, + FeatureFlags::TrackFileCompilation, + FeatureFlags::TrackMemoryAllocation, + FeatureFlags::TrackCpuTime, + FeatureFlags::TrackFileDefinitions, + FeatureFlags::TrackPdo, + FeatureFlags::TrackHttp, + FeatureFlags::TrackCaches, + FeatureFlags::TrackMongodb, + FeatureFlags::TrackElasticsearch, + FeatureFlags::TrackQueues, + FeatureFlags::TrackAwsSdk, + FeatureFlags::TrackFileOperations, + ]; + + foreach ($allFlags as $flag) { + $this->assertTrue( + (FeatureFlags::AllFlags & $flag) === $flag, + "AllFlags should contain flag $flag" + ); + } + } + + /** + * Test flag combinations work correctly + * @covers \Perfbase\SDK\FeatureFlags + */ + public function testFlagCombinations(): void + { + $combination = FeatureFlags::TrackCpuTime | FeatureFlags::TrackHttp; + + $this->assertTrue( + ($combination & FeatureFlags::TrackCpuTime) === FeatureFlags::TrackCpuTime, + 'Combined flags should contain TrackCpuTime' + ); + + $this->assertTrue( + ($combination & FeatureFlags::TrackHttp) === FeatureFlags::TrackHttp, + 'Combined flags should contain TrackHttp' + ); + + $this->assertFalse( + ($combination & FeatureFlags::TrackPdo) === FeatureFlags::TrackPdo, + 'Combined flags should not contain TrackPdo' + ); + } + + /** + * Test that we can check if a specific flag is enabled in a combination + * @covers \Perfbase\SDK\FeatureFlags + */ + public function testCheckingIndividualFlagsInCombination(): void + { + $flags = FeatureFlags::TrackCpuTime | FeatureFlags::TrackHttp | FeatureFlags::TrackPdo; + + // Test enabled flags + $this->assertTrue($this->isFlagEnabled($flags, FeatureFlags::TrackCpuTime)); + $this->assertTrue($this->isFlagEnabled($flags, FeatureFlags::TrackHttp)); + $this->assertTrue($this->isFlagEnabled($flags, FeatureFlags::TrackPdo)); + + // Test disabled flags + $this->assertFalse($this->isFlagEnabled($flags, FeatureFlags::TrackCaches)); + $this->assertFalse($this->isFlagEnabled($flags, FeatureFlags::TrackMongodb)); + } + + /** + * Test flag values are within expected ranges + * @covers \Perfbase\SDK\FeatureFlags + */ + public function testFlagValuesAreWithinExpectedRanges(): void + { + // All flags should be less than or equal to AllFlags + $individualFlags = [ + FeatureFlags::UseCoarseClock, + FeatureFlags::TrackExceptions, + FeatureFlags::TrackFileCompilation, + FeatureFlags::TrackMemoryAllocation, + FeatureFlags::TrackCpuTime, + FeatureFlags::TrackFileDefinitions, + FeatureFlags::TrackPdo, + FeatureFlags::TrackHttp, + FeatureFlags::TrackCaches, + FeatureFlags::TrackMongodb, + FeatureFlags::TrackElasticsearch, + FeatureFlags::TrackQueues, + FeatureFlags::TrackAwsSdk, + FeatureFlags::TrackFileOperations, + ]; + + foreach ($individualFlags as $flag) { + $this->assertLessThanOrEqual( + FeatureFlags::AllFlags, + $flag, + "Individual flag $flag should be less than or equal to AllFlags" + ); + } + + // DefaultFlags should be less than AllFlags + $this->assertLessThan(FeatureFlags::AllFlags, FeatureFlags::DefaultFlags); + } + + /** + * Helper method to check if a flag is enabled in a combination + */ + private function isFlagEnabled(int $flags, int $flag): bool + { + return ($flags & $flag) === $flag; + } +} \ No newline at end of file diff --git a/tests/Http/ApiClientTest.php b/tests/Http/ApiClientTest.php new file mode 100644 index 0000000..27f5664 --- /dev/null +++ b/tests/Http/ApiClientTest.php @@ -0,0 +1,194 @@ +mockHttpClient = Mockery::mock(HttpClientInterface::class); + $this->config = Config::fromArray([ + 'api_key' => 'test-api-key', + 'api_url' => 'https://test.example.com' + ]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @covers ::__construct + * @throws PerfbaseInvalidConfigException + */ + public function testConstructorWithMockedHttpClient(): void + { + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + + $this->assertInstanceOf(ApiClient::class, $apiClient); + } + + /** + * @covers ::__construct + * @throws PerfbaseInvalidConfigException + */ + public function testConstructorWithoutHttpClientCreatesDefault(): void + { + $apiClient = new ApiClient($this->config); + + $this->assertInstanceOf(ApiClient::class, $apiClient); + } + + /** + * @covers ::__construct + */ + public function testConstructorSetsCorrectHeaders(): void + { + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + + $headers = $this->getPrivateFieldValue($apiClient, 'defaultHeaders'); + + $this->assertEquals('Bearer test-api-key', $headers['Authorization']); + $this->assertEquals('application/json', $headers['Accept']); + $this->assertEquals('application/json', $headers['Content-Type']); + $this->assertEquals('keep-alive', $headers['Connection']); + $this->assertStringContainsString('Perfbase-PHP-SDK/', $headers['User-Agent']); + } + + /** + * @covers ::submitTrace + * @covers ::submit + */ + public function testSubmitTrace(): void + { + $testData = 'test-trace-data'; + + $this->mockHttpClient->shouldReceive('post') + ->once() + ->with('/v1/submit', Mockery::on(function ($options) use ($testData) { + return isset($options['body']) && $options['body'] === $testData + && isset($options['headers']) && is_array($options['headers']); + })); + + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + + $apiClient->submitTrace($testData); + + $this->assertTrue(true); // Verify no exception was thrown + } + + /** + * @covers ::submitTrace + * @covers ::submit + */ + public function testSubmitTraceWithCorrectHeaders(): void + { + $testData = 'test-trace-data'; + + $this->mockHttpClient->shouldReceive('post') + ->once() + ->with('/v1/submit', Mockery::on(function ($options) { + $headers = $options['headers']; + return $headers['Authorization'] === 'Bearer test-api-key' + && $headers['Accept'] === 'application/json' + && $headers['Content-Type'] === 'application/json' + && $headers['Connection'] === 'keep-alive' + && isset($headers['User-Agent']); + })); + + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + + $apiClient->submitTrace($testData); + + $this->assertTrue(true); // Verify no exception was thrown + } + + /** + * @covers ::submitTrace + * @covers ::submit + */ + public function testSubmitTraceWithEmptyData(): void + { + $this->mockHttpClient->shouldReceive('post') + ->once() + ->with('/v1/submit', Mockery::on(function ($options) { + return $options['body'] === ''; + })); + + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + + $apiClient->submitTrace(''); + + $this->assertTrue(true); // Verify no exception was thrown + } + + /** + * @covers ::__construct + */ + public function testConstructorWithProxyConfiguration(): void + { + $configWithProxy = Config::fromArray([ + 'api_key' => 'test-api-key', + 'api_url' => 'https://test.example.com', + 'proxy' => 'http://proxy.example.com:8080' + ]); + + // When not providing a mock HTTP client, it should create a real one with proxy config + $apiClient = new ApiClient($configWithProxy); + + $this->assertInstanceOf(ApiClient::class, $apiClient); + } + + /** + * @covers ::__construct + */ + public function testConstructorWithCustomTimeout(): void + { + $configWithTimeout = Config::fromArray([ + 'api_key' => 'test-api-key', + 'api_url' => 'https://test.example.com', + 'timeout' => 30 + ]); + + $apiClient = new ApiClient($configWithTimeout); + + $this->assertInstanceOf(ApiClient::class, $apiClient); + } + + /** + * Test that HTTP client exceptions are handled gracefully + * @covers ::submit + */ + public function testSubmitHandlesHttpClientExceptions(): void + { + $this->mockHttpClient->shouldReceive('post') + ->once() + ->andThrow(new \Exception('HTTP error')); + + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + + // Should throw exception since we removed silent failure handling + $this->expectException(\Exception::class); + $this->expectExceptionMessage('HTTP error'); + + $apiClient->submitTrace('test-data'); + } +} \ No newline at end of file diff --git a/tests/Http/GuzzleHttpClientTest.php b/tests/Http/GuzzleHttpClientTest.php new file mode 100644 index 0000000..605bec7 --- /dev/null +++ b/tests/Http/GuzzleHttpClientTest.php @@ -0,0 +1,118 @@ +mockGuzzleClient = Mockery::mock(GuzzleClient::class); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @covers ::__construct + */ + public function testConstructor(): void + { + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + + $this->assertInstanceOf(GuzzleHttpClient::class, $httpClient); + } + + /** + * @covers ::post + */ + public function testPostSuccess(): void + { + $uri = '/test/endpoint'; + $options = ['headers' => ['Content-Type' => 'application/json']]; + + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->with($uri, $options); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + + $httpClient->post($uri, $options); + + $this->assertTrue(true); // Verify no exception was thrown + } + + /** + * @covers ::post + */ + public function testPostWithEmptyOptions(): void + { + $uri = '/test/endpoint'; + + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->with($uri, []); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + + $httpClient->post($uri); + + $this->assertTrue(true); // Verify no exception was thrown + } + + /** + * @covers ::post + */ + public function testPostSilentlyHandlesExceptions(): void + { + $uri = '/test/endpoint'; + $options = ['body' => 'test data']; + + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->with($uri, $options) + ->andThrow(new \Exception('Network error')); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + + // Should not throw exception (silent failure as per design) + $httpClient->post($uri, $options); + + $this->assertTrue(true); // If we get here, exception was handled silently + } + + /** + * @covers ::post + */ + public function testPostSilentlyHandlesGuzzleExceptions(): void + { + $uri = '/test/endpoint'; + + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->with($uri, []) + ->andThrow(new \GuzzleHttp\Exception\RequestException('Request failed', + Mockery::mock(\Psr\Http\Message\RequestInterface::class))); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + + // Should not throw exception + $httpClient->post($uri); + + $this->assertTrue(true); + } +} \ No newline at end of file diff --git a/tests/Integration/PerfbaseIntegrationTest.php b/tests/Integration/PerfbaseIntegrationTest.php new file mode 100644 index 0000000..3a38e9b --- /dev/null +++ b/tests/Integration/PerfbaseIntegrationTest.php @@ -0,0 +1,263 @@ +mockExtension = Mockery::mock(ExtensionInterface::class); + $this->mockHttpClient = Mockery::mock(HttpClientInterface::class); + $this->mockApiClient = Mockery::mock(ApiClient::class); + + $this->config = Config::fromArray([ + 'api_key' => 'integration-test-key', + 'api_url' => 'https://integration.test.com', + 'timeout' => 15 + ]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * Test complete profiling workflow from start to submission + * @covers \Perfbase\SDK\Perfbase + */ + public function testCompleteProfilingWorkflow(): void + { + // Setup extension expectations + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('integration-span', $this->config->flags); + $this->mockExtension->shouldReceive('disable')->once()->with('integration-span'); + $this->mockExtension->shouldReceive('getData')->twice()->andReturn('integration-trace-data'); // Called by getTraceData and submitTrace + $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor + + // Setup API client expectations + $this->mockApiClient->shouldReceive('submitTrace')->once()->with('integration-trace-data'); + + // Create Perfbase instance + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + // Execute complete workflow + $perfbase->startTraceSpan('integration-span'); + + // Simulate some work happening... + + $stopResult = $perfbase->stopTraceSpan('integration-span'); + $this->assertTrue($stopResult); + + // Get and verify trace data + $traceData = $perfbase->getTraceData(); + $this->assertEquals('integration-trace-data', $traceData); + + // Submit trace + $perfbase->submitTrace(); + } + + /** + * Test multiple spans workflow + * @covers \Perfbase\SDK\Perfbase + */ + public function testMultipleSpansWorkflow(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + + // First span + $this->mockExtension->shouldReceive('enable')->once()->with('span-1', $this->config->flags); + $this->mockExtension->shouldReceive('disable')->once()->with('span-1'); + + // Second span + $this->mockExtension->shouldReceive('enable')->once()->with('span-2', $this->config->flags); + $this->mockExtension->shouldReceive('disable')->once()->with('span-2'); + + // Data retrieval and submission + $this->mockExtension->shouldReceive('getData')->once()->andReturn('multi-span-data'); + $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor + $this->mockApiClient->shouldReceive('submitTrace')->once()->with('multi-span-data'); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + // Start multiple spans + $perfbase->startTraceSpan('span-1'); + $perfbase->startTraceSpan('span-2'); + + // Stop spans in different order + $result2 = $perfbase->stopTraceSpan('span-2'); + $result1 = $perfbase->stopTraceSpan('span-1'); + + $this->assertTrue($result1); + $this->assertTrue($result2); + + // Submit combined trace + $perfbase->submitTrace(); + } + + /** + * Test workflow with configuration changes + * @covers \Perfbase\SDK\Perfbase + */ + public function testWorkflowWithConfigurationChanges(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + + // Initial span with default flags + $this->mockExtension->shouldReceive('enable')->once()->with('config-span', $this->config->flags); + + // After flag change + $newFlags = 2048; + $this->mockExtension->shouldReceive('enable')->once()->with('modified-span', $newFlags); + $this->mockExtension->shouldReceive('disable')->once()->with('config-span'); + $this->mockExtension->shouldReceive('disable')->once()->with('modified-span'); + $this->mockExtension->shouldReceive('getData')->once()->andReturn('config-change-data'); + $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor + $this->mockApiClient->shouldReceive('submitTrace')->once()->with('config-change-data'); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + // Start span with initial configuration + $perfbase->startTraceSpan('config-span'); + + // Change flags + $perfbase->setFlags($newFlags); + + // Start another span with new flags + $perfbase->startTraceSpan('modified-span'); + + // Stop both spans + $perfbase->stopTraceSpan('config-span'); + $perfbase->stopTraceSpan('modified-span'); + + // Submit trace + $perfbase->submitTrace(); + + $this->assertTrue(true); // Verify workflow completed successfully + } + + /** + * Test error handling in workflow + * @covers \Perfbase\SDK\Perfbase + */ + public function testErrorHandlingInWorkflow(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('error-span', $this->config->flags); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + // Start a span + $perfbase->startTraceSpan('error-span'); + + // Try to stop a non-existent span + $result = $perfbase->stopTraceSpan('non-existent-span'); + $this->assertFalse($result); + + // Properly stop the actual span + $this->mockExtension->shouldReceive('disable')->once()->with('error-span'); + $result = $perfbase->stopTraceSpan('error-span'); + $this->assertTrue($result); + } + + /** + * Test ApiClient integration with real HTTP client interface + * @covers \Perfbase\SDK\Http\ApiClient + */ + public function testApiClientIntegration(): void + { + $testData = 'api-integration-data'; + + $this->mockHttpClient->shouldReceive('post') + ->once() + ->with('/v1/submit', Mockery::on(function ($options) use ($testData) { + return $options['body'] === $testData + && isset($options['headers']['Authorization']) + && $options['headers']['Authorization'] === 'Bearer integration-test-key'; + })); + + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + + $apiClient->submitTrace($testData); + + $this->assertTrue(true); // Verify no exception was thrown + } + + /** + * Test full stack integration with API client + * @covers \Perfbase\SDK\Perfbase + */ + public function testFullStackIntegration(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('full-stack', $this->config->flags); + $this->mockExtension->shouldReceive('disable')->once()->with('full-stack'); + $this->mockExtension->shouldReceive('getData')->once()->andReturn('full-stack-data'); + $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor + + $this->mockHttpClient->shouldReceive('post') + ->once() + ->with('/v1/submit', Mockery::on(function ($options) { + return $options['body'] === 'full-stack-data' + && isset($options['headers']['Authorization']); + })); + + // Create real API client with mocked HTTP client + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $apiClient); + + // Execute full workflow + $perfbase->startTraceSpan('full-stack'); + $perfbase->stopTraceSpan('full-stack'); + $perfbase->submitTrace(); + + $this->assertTrue(true); // Verify full stack integration completed + } + + /** + * Test cleanup behavior + * @covers \Perfbase\SDK\Perfbase + */ + public function testCleanupBehavior(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('cleanup-span', $this->config->flags); + $this->mockExtension->shouldReceive('reset')->twice(); // Once manual, once destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $perfbase->startTraceSpan('cleanup-span'); + + // Manual reset + $perfbase->reset(); + + // Verify active spans are cleared + $activeSpans = $this->getPrivateFieldValue($perfbase, 'activeSpans'); + $this->assertEmpty($activeSpans); + + // Destructor should also call reset (verified by mock expectation) + unset($perfbase); + } +} \ No newline at end of file diff --git a/tests/PerfbaseTest.php b/tests/PerfbaseTest.php new file mode 100644 index 0000000..c87cbc7 --- /dev/null +++ b/tests/PerfbaseTest.php @@ -0,0 +1,316 @@ +mockExtension = Mockery::mock(ExtensionInterface::class); + $this->mockApiClient = Mockery::mock(ApiClient::class); + $this->config = Config::fromArray([ + 'api_key' => 'test-api-key', + 'api_url' => 'https://test.example.com' + ]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * @covers ::__construct + * @covers ::ensureIsAvailable + */ + public function testConstructorWithAvailableExtension(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $this->assertInstanceOf(Perfbase::class, $perfbase); + } + + /** + * @covers ::__construct + * @covers ::ensureIsAvailable + */ + public function testConstructorThrowsExceptionWhenExtensionUnavailable(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(false); + + $this->expectException(PerfbaseExtensionException::class); + $this->expectExceptionMessage('Perfbase extension is not available.'); + + new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + } + + /** + * @covers ::startTraceSpan + * @covers ::validateSpanName + */ + public function testStartTraceSpanWithValidName(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('test-span', $this->config->flags); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $perfbase->startTraceSpan('test-span'); + + // Verify span is active using reflection + $activeSpans = $this->getPrivateFieldValue($perfbase, 'activeSpans'); + $this->assertTrue($activeSpans['test-span']); + } + + /** + * @covers ::startTraceSpan + * @covers ::validateSpanName + */ + public function testStartTraceSpanWithEmptyNameUsesDefault(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('default', $this->config->flags); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $perfbase->startTraceSpan(' '); + + $activeSpans = $this->getPrivateFieldValue($perfbase, 'activeSpans'); + $this->assertTrue($activeSpans['default']); + } + + /** + * @covers ::startTraceSpan + * @covers ::validateSpanName + */ + public function testStartTraceSpanThrowsExceptionForTooLongName(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $longName = str_repeat('a', 65); + + $this->expectException(PerfbaseInvalidSpanException::class); + $this->expectExceptionMessage('Span name exceeds maximum length of 64 characters.'); + + $perfbase->startTraceSpan($longName); + } + + /** + * @covers ::startTraceSpan + * @covers ::validateSpanName + */ + public function testStartTraceSpanThrowsExceptionForInvalidCharacters(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $this->expectException(PerfbaseInvalidSpanException::class); + $this->expectExceptionMessage('Span name contains invalid characters. Only alphanumeric characters, hyphens and underscores are allowed.'); + + $perfbase->startTraceSpan('invalid@span!'); + } + + /** + * @covers ::startTraceSpan + */ + public function testStartTraceSpanWarnsWhenSpanAlreadyActive(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('test-span', $this->config->flags); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + // Start span first time + $perfbase->startTraceSpan('test-span'); + + // Capture warnings using a custom error handler + $warningTriggered = false; + $warningMessage = ''; + + set_error_handler(function($errno, $errstr) use (&$warningTriggered, &$warningMessage) { + if ($errno === E_USER_WARNING) { + $warningTriggered = true; + $warningMessage = $errstr; + } + return true; // Suppress the warning + }); + + // Attempt to start same span again should trigger warning + $perfbase->startTraceSpan('test-span'); + + // Restore original error handler + restore_error_handler(); + + // Assert that warning was triggered with correct message + $this->assertTrue($warningTriggered, 'Expected warning was not triggered'); + $this->assertStringContainsString('Perfbase: Attempted to start span "test-span" which is already active.', $warningMessage); + } + + /** + * @covers ::stopTraceSpan + * @covers ::isSpanActive + * @covers ::validateSpanName + */ + public function testStopTraceSpanWhenActive(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('test-span', $this->config->flags); + $this->mockExtension->shouldReceive('disable')->once()->with('test-span'); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $perfbase->startTraceSpan('test-span'); + $result = $perfbase->stopTraceSpan('test-span'); + + $this->assertTrue($result); + + // Verify span is no longer active + $activeSpans = $this->getPrivateFieldValue($perfbase, 'activeSpans'); + $this->assertFalse(isset($activeSpans['test-span'])); + } + + /** + * @covers ::stopTraceSpan + * @covers ::isSpanActive + */ + public function testStopTraceSpanWhenNotActive(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $result = $perfbase->stopTraceSpan('non-existent-span'); + + $this->assertFalse($result); + } + + /** + * @covers ::setFlags + */ + public function testSetFlags(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $perfbase->setFlags(1024); + + $config = $this->getPrivateFieldValue($perfbase, 'config'); + $this->assertEquals(1024, $config->flags); + } + + /** + * @covers ::getTraceData + */ + public function testGetTraceData(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('getData')->once()->andReturn('trace-data'); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $result = $perfbase->getTraceData(); + + $this->assertEquals('trace-data', $result); + } + + /** + * @covers ::submitTrace + * @covers ::reset + */ + public function testSubmitTrace(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('getData')->once()->andReturn('trace-data'); + $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor + $this->mockApiClient->shouldReceive('submitTrace')->once()->with('trace-data'); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $perfbase->submitTrace(); + + $this->assertTrue(true); // Verify submitTrace completed successfully + } + + /** + * @covers ::reset + */ + public function testReset(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('enable')->once()->with('test-span', $this->config->flags); + $this->mockExtension->shouldReceive('reset')->twice(); // Called manually and by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $perfbase->startTraceSpan('test-span'); + $perfbase->reset(); + + // Verify active spans are cleared + $activeSpans = $this->getPrivateFieldValue($perfbase, 'activeSpans'); + $this->assertEmpty($activeSpans); + } + + /** + * @covers ::isExtensionAvailable + */ + public function testIsExtensionAvailable(): void + { + $this->mockExtension->shouldReceive('isAvailable')->twice()->andReturn(true); + $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $this->assertTrue($perfbase->isExtensionAvailable()); + } + + /** + * @covers ::__destruct + */ + public function testDestructorCallsReset(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('reset')->once(); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + // Trigger destructor + unset($perfbase); + + $this->assertTrue(true); // Verify destructor was called without issues + } +} \ No newline at end of file diff --git a/tests/Utils/ExtensionUtilsTest.php b/tests/Utils/ExtensionUtilsTest.php new file mode 100644 index 0000000..bb7f76f --- /dev/null +++ b/tests/Utils/ExtensionUtilsTest.php @@ -0,0 +1,149 @@ +assertIsBool($result); + } + + /** + * @covers ::perfbaseMethodsAvailable + */ + public function testPerfbaseMethodsAvailableReturnsBooleanValue(): void + { + $result = ExtensionUtils::perfbaseMethodsAvailable(); + + $this->assertIsBool($result); + } + + /** + * @covers ::perfbaseMethodsAvailable + */ + public function testPerfbaseMethodsAvailableChecksAllRequiredMethods(): void + { + // Get the private methods array using reflection + $reflection = new \ReflectionClass(ExtensionUtils::class); + $methodsProperty = $reflection->getProperty('methods'); + $methodsProperty->setAccessible(true); + $requiredMethods = $methodsProperty->getValue(); + + $expectedMethods = [ + 'perfbase_enable', + 'perfbase_disable', + 'perfbase_reset', + 'perfbase_get_data', + 'perfbase_set_attribute' + ]; + + $this->assertEquals($expectedMethods, $requiredMethods); + } + + /** + * Test the actual functionality when extension is not loaded + * @covers ::perfbaseExtensionLoaded + */ + public function testPerfbaseExtensionLoadedWhenNotLoaded(): void + { + // If the extension is not loaded, this should return false + if (!extension_loaded('perfbase')) { + $this->assertFalse(ExtensionUtils::perfbaseExtensionLoaded()); + } else { + $this->assertTrue(ExtensionUtils::perfbaseExtensionLoaded()); + } + } + + /** + * Test the actual functionality when methods are not available + * @covers ::perfbaseMethodsAvailable + */ + public function testPerfbaseMethodsAvailableWhenMethodsNotDefined(): void + { + $result = ExtensionUtils::perfbaseMethodsAvailable(); + + // This will depend on whether the extension is actually loaded + // The test verifies the method returns a boolean and doesn't crash + $this->assertIsBool($result); + + // If no extension is loaded, it should return false + if (!ExtensionUtils::perfbaseExtensionLoaded()) { + $this->assertFalse($result); + } + } + + /** + * Test that the methods being checked exist in the expected functions + * @covers ::perfbaseMethodsAvailable + */ + public function testMethodsArrayContainsExpectedFunctions(): void + { + // Use reflection to access the private methods array + $reflection = new \ReflectionClass(ExtensionUtils::class); + $methodsProperty = $reflection->getProperty('methods'); + $methodsProperty->setAccessible(true); + $methods = $methodsProperty->getValue(); + + // Verify all expected methods are in the array + $this->assertContains('perfbase_enable', $methods); + $this->assertContains('perfbase_disable', $methods); + $this->assertContains('perfbase_reset', $methods); + $this->assertContains('perfbase_get_data', $methods); + $this->assertContains('perfbase_set_attribute', $methods); + + // Verify the array has exactly 5 methods + $this->assertCount(5, $methods); + } + + /** + * Test integration between the two methods + * @covers ::perfbaseExtensionLoaded + * @covers ::perfbaseMethodsAvailable + */ + public function testIntegrationBetweenExtensionAndMethodChecks(): void + { + $extensionLoaded = ExtensionUtils::perfbaseExtensionLoaded(); + $methodsAvailable = ExtensionUtils::perfbaseMethodsAvailable(); + + // If extension is not loaded, methods should not be available + if (!$extensionLoaded) { + $this->assertFalse($methodsAvailable, + 'Methods should not be available if extension is not loaded'); + } + + // Both should return boolean values + $this->assertIsBool($extensionLoaded); + $this->assertIsBool($methodsAvailable); + } + + /** + * Test that calling methods multiple times gives consistent results + * @covers ::perfbaseExtensionLoaded + * @covers ::perfbaseMethodsAvailable + */ + public function testConsistencyOfResults(): void + { + $extensionResult1 = ExtensionUtils::perfbaseExtensionLoaded(); + $extensionResult2 = ExtensionUtils::perfbaseExtensionLoaded(); + + $methodsResult1 = ExtensionUtils::perfbaseMethodsAvailable(); + $methodsResult2 = ExtensionUtils::perfbaseMethodsAvailable(); + + $this->assertEquals($extensionResult1, $extensionResult2, + 'Extension loaded check should be consistent'); + $this->assertEquals($methodsResult1, $methodsResult2, + 'Methods available check should be consistent'); + } +} \ No newline at end of file From 84501303f4549dfadb9b21941cdcfe68fa89c161 Mon Sep 17 00:00:00 2001 From: Ben Poulson Date: Wed, 9 Jul 2025 09:57:41 +0100 Subject: [PATCH 2/4] Remove span name validation - now handled by the API --- src/Perfbase.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Perfbase.php b/src/Perfbase.php index 5cb6e79..205be58 100644 --- a/src/Perfbase.php +++ b/src/Perfbase.php @@ -134,16 +134,6 @@ private function validateSpanName(string $spanName): string $spanName = self::DEFAULT_SPAN_NAME; } - // Check if the span name exceeds the maximum length - if (strlen($spanName) > 64) { - throw new PerfbaseInvalidSpanException('Span name exceeds maximum length of 64 characters.'); - } - - // Only allow alphanumeric characters, hyphens and underscores - if (!preg_match('/^[a-zA-Z0-9-_]+$/', $spanName)) { - throw new PerfbaseInvalidSpanException('Span name contains invalid characters. Only alphanumeric characters, hyphens and underscores are allowed.'); - } - return $spanName; } From 0db3988c107f903f41c0e3e66c6883d1b38b0b80 Mon Sep 17 00:00:00 2001 From: Ben Poulson Date: Wed, 9 Jul 2025 10:01:49 +0100 Subject: [PATCH 3/4] CI tests should install extension --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d65748..e537bed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,8 @@ jobs: php-version: ${{ matrix.php-versions }} tools: composer - - name: Validate composer.json and composer.lock - run: composer validate --no-check-all --no-check-publish + - name: Install Perfbase + run: bash -c "$(curl -fsSL https://cdn.perfbase.com/install.sh)" - name: Install dependencies run: composer install --prefer-dist --no-progress From 4b1a102d949e7867f3ac961757047ed16a78a17a Mon Sep 17 00:00:00 2001 From: Ben Poulson Date: Wed, 9 Jul 2025 10:18:45 +0100 Subject: [PATCH 4/4] Remove obsolete tests --- tests/PerfbaseTest.php | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/tests/PerfbaseTest.php b/tests/PerfbaseTest.php index c87cbc7..0120dc2 100644 --- a/tests/PerfbaseTest.php +++ b/tests/PerfbaseTest.php @@ -103,42 +103,6 @@ public function testStartTraceSpanWithEmptyNameUsesDefault(): void $this->assertTrue($activeSpans['default']); } - /** - * @covers ::startTraceSpan - * @covers ::validateSpanName - */ - public function testStartTraceSpanThrowsExceptionForTooLongName(): void - { - $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); - $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor - - $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); - - $longName = str_repeat('a', 65); - - $this->expectException(PerfbaseInvalidSpanException::class); - $this->expectExceptionMessage('Span name exceeds maximum length of 64 characters.'); - - $perfbase->startTraceSpan($longName); - } - - /** - * @covers ::startTraceSpan - * @covers ::validateSpanName - */ - public function testStartTraceSpanThrowsExceptionForInvalidCharacters(): void - { - $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); - $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor - - $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); - - $this->expectException(PerfbaseInvalidSpanException::class); - $this->expectExceptionMessage('Span name contains invalid characters. Only alphanumeric characters, hyphens and underscores are allowed.'); - - $perfbase->startTraceSpan('invalid@span!'); - } - /** * @covers ::startTraceSpan */