diff --git a/README.md b/README.md index 6feccf5..0459d24 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ registerUser($userData); $perfbase->stopTraceSpan('user_registration'); // Submit the trace data to Perfbase -$perfbase->submitTrace(); +$result = $perfbase->submitTrace(); ``` ### Advanced Usage with Attributes @@ -144,7 +144,7 @@ try { } // Submit trace data -$perfbase->submitTrace(); +$result = $perfbase->submitTrace(); ``` ### Multiple Concurrent Spans @@ -169,7 +169,7 @@ accessCache(); $perfbase->stopTraceSpan('cache_operations'); // Submit all trace data -$perfbase->submitTrace(); +$result = $perfbase->submitTrace(); ``` ## Configuration Options @@ -265,11 +265,23 @@ Add a custom attribute to the current trace. $perfbase->setAttribute('cache_hit_ratio', '0.85'); ``` -#### `submitTrace(): void` -Submit collected profiling data to Perfbase and reset the session. +#### `submitTrace(): SubmitResult` +Submit collected profiling data to Perfbase. Resets the session on success; preserves trace state on failure so callers can decide whether to retry or discard. + +The returned `SubmitResult` provides: +- `isSuccess(): bool` — delivery confirmed +- `isRetryable(): bool` — transient failure (network error, 429, 5xx) +- `isPermanentFailure(): bool` — non-retryable (401, 403, 400, etc.) +- `getStatusCode(): ?int` — HTTP status code if a response was received +- `getMessage(): string` — human-readable description ```php -$perfbase->submitTrace(); +$result = $perfbase->submitTrace(); + +if (!$result->isSuccess()) { + // Handle failure — trace state is preserved for retry + error_log("Trace submission failed: " . $result->getMessage()); +} ``` #### `getTraceData(string $spanName = ''): string` @@ -327,6 +339,15 @@ try { error_log("Perfbase extension not available: " . $e->getMessage()); // Your application continues normally } + +// Submission failures are returned as SubmitResult, not thrown +$result = $perfbase->submitTrace(); +if ($result->isRetryable()) { + // Transient failure — safe to retry later +} elseif ($result->isPermanentFailure()) { + // Non-retryable — check API key, payload, etc. + error_log("Permanent submission failure: " . $result->getMessage()); +} ``` ## Best Practices diff --git a/src/Http/ApiClient.php b/src/Http/ApiClient.php index 07efe34..3b5644c 100644 --- a/src/Http/ApiClient.php +++ b/src/Http/ApiClient.php @@ -4,9 +4,8 @@ use GuzzleHttp\Client as GuzzleClient; use Perfbase\SDK\Config; -use Perfbase\SDK\Http\HttpClientInterface; -use Perfbase\SDK\Http\GuzzleHttpClient; use Perfbase\SDK\Perfbase; +use Perfbase\SDK\SubmitResult; class ApiClient { @@ -42,18 +41,15 @@ public function __construct(Config $config, ?HttpClientInterface $httpClient = n 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 the HTTP client $guzzleClient = new GuzzleClient($httpClientConfig); $this->httpClient = new GuzzleHttpClient($guzzleClient); } @@ -63,11 +59,11 @@ public function __construct(Config $config, ?HttpClientInterface $httpClient = n * Submits a trace to the Perfbase API * * @param string $perfData Data to send in the request body - * @return void + * @return SubmitResult */ - public function submitTrace(string $perfData): void + public function submitTrace(string $perfData): SubmitResult { - $this->submit('/v1/submit', $perfData); + return $this->submit('/v1/submit', $perfData); } /** @@ -75,17 +71,15 @@ public function submitTrace(string $perfData): void * * @param string $endpoint API endpoint to send the request to * @param string $perfData Data to send in the request body - * @return void + * @return SubmitResult */ - private function submit(string $endpoint, string $perfData): void + private function submit(string $endpoint, string $perfData): SubmitResult { - // Prepare request options $options = [ - 'headers' => array_merge($this->defaultHeaders, []), + 'headers' => $this->defaultHeaders, 'body' => $perfData, ]; - $this->httpClient->post($endpoint, $options); + return $this->httpClient->post($endpoint, $options); } - } diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index d75a3f7..d5b220a 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -3,6 +3,9 @@ namespace Perfbase\SDK\Http; use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; +use Perfbase\SDK\SubmitResult; use Throwable; class GuzzleHttpClient implements HttpClientInterface @@ -14,13 +17,49 @@ public function __construct(GuzzleClient $client) $this->client = $client; } - public function post(string $uri, array $options = []): void + public function post(string $uri, array $options = []): SubmitResult { try { - $this->client->post($uri, $options); + $response = $this->client->post($uri, $options); + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 200 && $statusCode < 300) { + return SubmitResult::success($statusCode); + } + + // Non-2xx that Guzzle didn't throw on (http_errors disabled) + return $this->classifyHttpStatus($statusCode); + } catch (ConnectException $e) { + // Network-level failure: DNS, timeout, connection refused — always retryable + return SubmitResult::retryableFailure(null, $e->getMessage()); + } catch (RequestException $e) { + // HTTP error response (4xx/5xx) + $response = $e->getResponse(); + $statusCode = $response !== null ? $response->getStatusCode() : null; + return $this->classifyHttpStatus($statusCode, $e->getMessage()); } catch (Throwable $e) { - // Silent failure as per original implementation - // Could be made configurable in the future + // Unexpected failure — treat as retryable + return SubmitResult::retryableFailure(null, $e->getMessage()); } } -} \ No newline at end of file + + /** + * Classify an HTTP status code into a submit result. + * + * 429 and 5xx are retryable. 4xx (except 429) are permanent. + */ + private function classifyHttpStatus(?int $statusCode, string $message = ''): SubmitResult + { + if ($statusCode === null) { + return SubmitResult::retryableFailure(null, $message); + } + + // Rate limited or server errors — retryable + if ($statusCode === 429 || $statusCode >= 500) { + return SubmitResult::retryableFailure($statusCode, $message); + } + + // Client errors (400, 401, 403, 404, etc.) — permanent + return SubmitResult::permanentFailure($statusCode, $message); + } +} diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php index 48d7cd0..3d623b0 100644 --- a/src/Http/HttpClientInterface.php +++ b/src/Http/HttpClientInterface.php @@ -2,14 +2,16 @@ namespace Perfbase\SDK\Http; +use Perfbase\SDK\SubmitResult; + interface HttpClientInterface { /** - * Send a POST request + * Send a POST request and return a structured result. + * * @param string $uri The URI to send the request to * @param array $options Request options including headers, body, etc. - * @return void - * @throws \Throwable If the request fails + * @return SubmitResult */ - public function post(string $uri, array $options = []): void; -} \ No newline at end of file + public function post(string $uri, array $options = []): SubmitResult; +} diff --git a/src/Perfbase.php b/src/Perfbase.php index 9c8d0bc..6bc2c59 100644 --- a/src/Perfbase.php +++ b/src/Perfbase.php @@ -8,6 +8,7 @@ use Perfbase\SDK\Extension\ExtensionInterface; use Perfbase\SDK\Extension\PerfbaseExtension; use Perfbase\SDK\Http\ApiClient; +use Perfbase\SDK\SubmitResult; /** * Main client class for the Perfbase SDK @@ -157,15 +158,24 @@ public function setFlags(int $flags): void } /** - * Sends collected profiling data to the API - * @return void + * Sends collected profiling data to the API. + * + * Resets trace state on success. On failure, trace state is preserved + * so the caller can decide whether to retry or discard. + * + * @return SubmitResult */ - public function submitTrace(): void + public function submitTrace(): SubmitResult { - $this->apiClient->submitTrace( - $this->getTraceData() - ); - $this->reset(); + $payload = TracePayloadFactory::build($this->getTraceData()); + + $result = $this->apiClient->submitTrace($payload); + + if ($result->isSuccess()) { + $this->reset(); + } + + return $result; } /** diff --git a/src/SubmitResult.php b/src/SubmitResult.php new file mode 100644 index 0000000..9163622 --- /dev/null +++ b/src/SubmitResult.php @@ -0,0 +1,76 @@ +status = $status; + $this->statusCode = $statusCode; + $this->message = $message; + } + + public static function success(?int $statusCode = 200, string $message = ''): self + { + return new self(self::STATUS_SUCCESS, $statusCode, $message); + } + + public static function retryableFailure(?int $statusCode = null, string $message = ''): self + { + return new self(self::STATUS_RETRYABLE_FAILURE, $statusCode, $message); + } + + public static function permanentFailure(?int $statusCode = null, string $message = ''): self + { + return new self(self::STATUS_PERMANENT_FAILURE, $statusCode, $message); + } + + public function isSuccess(): bool + { + return $this->status === self::STATUS_SUCCESS; + } + + public function isRetryable(): bool + { + return $this->status === self::STATUS_RETRYABLE_FAILURE; + } + + public function isPermanentFailure(): bool + { + return $this->status === self::STATUS_PERMANENT_FAILURE; + } + + public function getStatus(): string + { + return $this->status; + } + + public function getStatusCode(): ?int + { + return $this->statusCode; + } + + public function getMessage(): string + { + return $this->message; + } +} diff --git a/src/TracePayloadFactory.php b/src/TracePayloadFactory.php new file mode 100644 index 0000000..a176362 --- /dev/null +++ b/src/TracePayloadFactory.php @@ -0,0 +1,54 @@ +"}. This factory + * owns the outer envelope — it validates structure, injects the client + * timestamp, and produces the final JSON body for the receiver. + */ +class TracePayloadFactory +{ + /** + * Build a submission payload from raw extension output. + * + * @param string $extensionOutput Raw JSON from perfbase_get_data() + * @return string JSON payload ready for HTTP submission + * @throws PerfbaseException If the extension output is malformed + */ + public static function build(string $extensionOutput): string + { + if ($extensionOutput === '') { + throw new PerfbaseException('Extension returned empty trace data'); + } + + /** @var mixed $decoded */ + $decoded = json_decode($extensionOutput, true); + + if (!is_array($decoded)) { + throw new PerfbaseException('Extension returned invalid JSON'); + } + + if (!isset($decoded['v']) || !is_int($decoded['v'])) { + throw new PerfbaseException('Extension output missing version field (v)'); + } + + if (!isset($decoded['p']) || !is_string($decoded['p'])) { + throw new PerfbaseException('Extension output missing payload field (p)'); + } + + // Inject client timestamp so the receiver knows when the trace was created + $decoded['d'] = gmdate('Y-m-d\TH:i:s\Z'); + + $json = json_encode($decoded); + if ($json === false) { + throw new PerfbaseException('Failed to encode submission payload'); + } + + return $json; + } +} diff --git a/tests/ApiClientTest.php b/tests/ApiClientTest.php deleted file mode 100644 index 29996a1..0000000 --- a/tests/ApiClientTest.php +++ /dev/null @@ -1,39 +0,0 @@ -assertInstanceOf(ApiClient::class, $apiClient); - } - - /** - * @return void - * @covers ::__construct - * @throws PerfbaseInvalidConfigException - */ - public function testInitializesWithValidNullUrl(): void - { - $config = Config::new('test_api_key', 0, null); - $apiClient = new ApiClient($config); - $this->assertInstanceOf(ApiClient::class, $apiClient); - } - -} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index a7e40f0..8f8bad4 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -109,4 +109,67 @@ public function testThrowsExceptionIfFlagsInvalidTooHigh(): void Config::new('abc123', FeatureFlags::AllFlags + 1); } + /** + * @covers ::validate + */ + public function testThrowsExceptionIfTimeoutIsZero(): void + { + $this->expectException(PerfbaseInvalidConfigException::class); + $this->expectExceptionMessage('Timeout must be a positive integer'); + Config::new('abc123', 0, null, null, 0); + } + + /** + * @covers ::validate + */ + public function testThrowsExceptionIfTimeoutIsNegative(): void + { + $this->expectException(PerfbaseInvalidConfigException::class); + Config::new('abc123', 0, null, null, -5); + } + + /** + * @covers ::fromArray + */ + public function testFromArrayThrowsOnUnknownKey(): void + { + $this->expectException(PerfbaseInvalidConfigException::class); + $this->expectExceptionMessage('Invalid configuration option'); + Config::fromArray(['api_key' => 'test', 'unknown_key' => 'value']); + } + + /** + * @covers ::fromArray + */ + public function testFromArrayWithAllOptions(): void + { + $config = Config::fromArray([ + 'api_key' => 'my-key', + 'api_url' => 'https://custom.api.com', + 'proxy' => 'http://proxy:8080', + 'timeout' => 30, + 'flags' => FeatureFlags::TrackCpuTime | FeatureFlags::TrackPdo, + ]); + + $this->assertSame('my-key', $config->api_key); + $this->assertSame('https://custom.api.com', $config->api_url); + $this->assertSame('http://proxy:8080', $config->proxy); + $this->assertSame(30, $config->timeout); + $this->assertSame(FeatureFlags::TrackCpuTime | FeatureFlags::TrackPdo, $config->flags); + } + + /** + * @covers ::new + */ + public function testNewWithDefaultValues(): void + { + $config = Config::new('my-key'); + + $this->assertSame('my-key', $config->api_key); + $this->assertSame('https://receiver.perfbase.com', $config->api_url); + $this->assertNull($config->proxy); + $this->assertSame(10, $config->timeout); + $this->assertSame(FeatureFlags::DefaultFlags, $config->flags); + } + } diff --git a/tests/Http/ApiClientTest.php b/tests/Http/ApiClientTest.php index 27f5664..cf72bcc 100644 --- a/tests/Http/ApiClientTest.php +++ b/tests/Http/ApiClientTest.php @@ -8,6 +8,7 @@ use Perfbase\SDK\Exception\PerfbaseInvalidConfigException; use Perfbase\SDK\Http\ApiClient; use Perfbase\SDK\Http\HttpClientInterface; +use Perfbase\SDK\SubmitResult; use Perfbase\SDK\Tests\BaseTest; /** @@ -21,7 +22,7 @@ class ApiClientTest extends BaseTest protected function setUp(): void { parent::setUp(); - + $this->mockHttpClient = Mockery::mock(HttpClientInterface::class); $this->config = Config::fromArray([ 'api_key' => 'test-api-key', @@ -42,7 +43,6 @@ protected function tearDown(): void public function testConstructorWithMockedHttpClient(): void { $apiClient = new ApiClient($this->config, $this->mockHttpClient); - $this->assertInstanceOf(ApiClient::class, $apiClient); } @@ -53,7 +53,6 @@ public function testConstructorWithMockedHttpClient(): void public function testConstructorWithoutHttpClientCreatesDefault(): void { $apiClient = new ApiClient($this->config); - $this->assertInstanceOf(ApiClient::class, $apiClient); } @@ -63,9 +62,9 @@ public function testConstructorWithoutHttpClientCreatesDefault(): void 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']); @@ -77,32 +76,32 @@ public function testConstructorSetsCorrectHeaders(): void * @covers ::submitTrace * @covers ::submit */ - public function testSubmitTrace(): void + public function testSubmitTraceReturnsResult(): void { $testData = 'test-trace-data'; - + $expectedResult = SubmitResult::success(202); + $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']); - })); - + })) + ->andReturn($expectedResult); + $apiClient = new ApiClient($this->config, $this->mockHttpClient); - - $apiClient->submitTrace($testData); - - $this->assertTrue(true); // Verify no exception was thrown + $result = $apiClient->submitTrace($testData); + + $this->assertTrue($result->isSuccess()); + $this->assertSame(202, $result->getStatusCode()); } /** * @covers ::submitTrace * @covers ::submit */ - public function testSubmitTraceWithCorrectHeaders(): void + public function testSubmitTracePassesCorrectHeaders(): void { - $testData = 'test-trace-data'; - $this->mockHttpClient->shouldReceive('post') ->once() ->with('/v1/submit', Mockery::on(function ($options) { @@ -112,13 +111,13 @@ public function testSubmitTraceWithCorrectHeaders(): void && $headers['Content-Type'] === 'application/json' && $headers['Connection'] === 'keep-alive' && isset($headers['User-Agent']); - })); - + })) + ->andReturn(SubmitResult::success()); + $apiClient = new ApiClient($this->config, $this->mockHttpClient); - - $apiClient->submitTrace($testData); - - $this->assertTrue(true); // Verify no exception was thrown + $result = $apiClient->submitTrace('test-data'); + + $this->assertTrue($result->isSuccess()); } /** @@ -131,13 +130,31 @@ public function testSubmitTraceWithEmptyData(): void ->once() ->with('/v1/submit', Mockery::on(function ($options) { return $options['body'] === ''; - })); - + })) + ->andReturn(SubmitResult::success()); + + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + $result = $apiClient->submitTrace(''); + + $this->assertTrue($result->isSuccess()); + } + + /** + * @covers ::submitTrace + */ + public function testSubmitTracePropagateshFailureResult(): void + { + $failureResult = SubmitResult::retryableFailure(503, 'Service Unavailable'); + + $this->mockHttpClient->shouldReceive('post') + ->once() + ->andReturn($failureResult); + $apiClient = new ApiClient($this->config, $this->mockHttpClient); - - $apiClient->submitTrace(''); - - $this->assertTrue(true); // Verify no exception was thrown + $result = $apiClient->submitTrace('test-data'); + + $this->assertTrue($result->isRetryable()); + $this->assertSame(503, $result->getStatusCode()); } /** @@ -150,10 +167,8 @@ public function testConstructorWithProxyConfiguration(): void '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); } @@ -167,28 +182,8 @@ public function testConstructorWithCustomTimeout(): void '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 index 605bec7..5a682b0 100644 --- a/tests/Http/GuzzleHttpClientTest.php +++ b/tests/Http/GuzzleHttpClientTest.php @@ -3,9 +3,14 @@ namespace Perfbase\SDK\Tests\Http; use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; use Mockery; use Mockery\MockInterface; use Perfbase\SDK\Http\GuzzleHttpClient; +use Perfbase\SDK\SubmitResult; use Perfbase\SDK\Tests\BaseTest; /** @@ -33,86 +38,186 @@ protected function tearDown(): void public function testConstructor(): void { $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); - $this->assertInstanceOf(GuzzleHttpClient::class, $httpClient); } /** * @covers ::post */ - public function testPostSuccess(): void + public function testPostReturnsSuccessOn2xx(): void { - $uri = '/test/endpoint'; - $options = ['headers' => ['Content-Type' => 'application/json']]; - $this->mockGuzzleClient->shouldReceive('post') ->once() - ->with($uri, $options); - + ->andReturn(new Response(202)); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + $result = $httpClient->post('/test/endpoint', ['body' => 'data']); + + $this->assertInstanceOf(SubmitResult::class, $result); + $this->assertTrue($result->isSuccess()); + $this->assertSame(202, $result->getStatusCode()); + } + + /** + * @covers ::post + */ + public function testPostReturnsRetryableFailureOnConnectionError(): void + { + $request = new Request('POST', '/test'); + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->andThrow(new ConnectException('Connection refused', $request)); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isRetryable()); + $this->assertNull($result->getStatusCode()); + $this->assertStringContainsString('Connection refused', $result->getMessage()); + } + + /** + * @covers ::post + * @covers ::classifyHttpStatus + */ + public function testPostReturnsRetryableFailureOn5xx(): void + { + $request = new Request('POST', '/test'); + $response = new Response(503); + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->andThrow(new RequestException('Service Unavailable', $request, $response)); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isRetryable()); + $this->assertSame(503, $result->getStatusCode()); + } + + /** + * @covers ::post + * @covers ::classifyHttpStatus + */ + public function testPostReturnsRetryableFailureOn429(): void + { + $request = new Request('POST', '/test'); + $response = new Response(429); + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->andThrow(new RequestException('Too Many Requests', $request, $response)); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isRetryable()); + $this->assertSame(429, $result->getStatusCode()); + } + + /** + * @covers ::post + * @covers ::classifyHttpStatus + */ + public function testPostReturnsPermanentFailureOn4xx(): void + { + $request = new Request('POST', '/test'); + $response = new Response(401); + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->andThrow(new RequestException('Unauthorized', $request, $response)); + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); - - $httpClient->post($uri, $options); - - $this->assertTrue(true); // Verify no exception was thrown + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isPermanentFailure()); + $this->assertSame(401, $result->getStatusCode()); } /** * @covers ::post */ - public function testPostWithEmptyOptions(): void + public function testPostReturnsRetryableFailureOnUnexpectedException(): void { - $uri = '/test/endpoint'; - $this->mockGuzzleClient->shouldReceive('post') ->once() - ->with($uri, []); - + ->andThrow(new \RuntimeException('Unexpected error')); + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); - - $httpClient->post($uri); - - $this->assertTrue(true); // Verify no exception was thrown + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isRetryable()); + $this->assertStringContainsString('Unexpected error', $result->getMessage()); } /** * @covers ::post + * @covers ::classifyHttpStatus */ - public function testPostSilentlyHandlesExceptions(): void + public function testPostReturnsRetryableOnRequestExceptionWithNoResponse(): void { - $uri = '/test/endpoint'; - $options = ['body' => 'test data']; - + $request = new Request('POST', '/test'); $this->mockGuzzleClient->shouldReceive('post') ->once() - ->with($uri, $options) - ->andThrow(new \Exception('Network error')); - + ->andThrow(new RequestException('No response received', $request)); + $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 + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isRetryable()); + $this->assertNull($result->getStatusCode()); } /** * @covers ::post */ - public function testPostSilentlyHandlesGuzzleExceptions(): void + public function testPostReturnsSuccessOn200(): 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))); - + ->andReturn(new Response(200)); + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); - - // Should not throw exception - $httpClient->post($uri); - - $this->assertTrue(true); + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isSuccess()); + $this->assertSame(200, $result->getStatusCode()); + } + + /** + * @covers ::post + * @covers ::classifyHttpStatus + */ + public function testPostReturnsPermanentFailureOn400(): void + { + $request = new Request('POST', '/test'); + $response = new Response(400); + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->andThrow(new RequestException('Bad Request', $request, $response)); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isPermanentFailure()); + $this->assertSame(400, $result->getStatusCode()); + } + + /** + * @covers ::post + * @covers ::classifyHttpStatus + */ + public function testPostReturnsRetryableOn500(): void + { + $request = new Request('POST', '/test'); + $response = new Response(500); + $this->mockGuzzleClient->shouldReceive('post') + ->once() + ->andThrow(new RequestException('Internal Server Error', $request, $response)); + + $httpClient = new GuzzleHttpClient($this->mockGuzzleClient); + $result = $httpClient->post('/test/endpoint'); + + $this->assertTrue($result->isRetryable()); + $this->assertSame(500, $result->getStatusCode()); } -} \ No newline at end of file +} diff --git a/tests/Integration/PerfbaseIntegrationTest.php b/tests/Integration/PerfbaseIntegrationTest.php index de255cd..f642d74 100644 --- a/tests/Integration/PerfbaseIntegrationTest.php +++ b/tests/Integration/PerfbaseIntegrationTest.php @@ -9,6 +9,7 @@ use Perfbase\SDK\Http\ApiClient; use Perfbase\SDK\Http\HttpClientInterface; use Perfbase\SDK\Perfbase; +use Perfbase\SDK\SubmitResult; use Perfbase\SDK\Tests\BaseTest; /** @@ -16,6 +17,9 @@ */ class PerfbaseIntegrationTest extends BaseTest { + /** Valid extension JSON output for use in tests that call submitTrace() */ + private const EXTENSION_JSON = '{"v":1,"p":"dGVzdA=="}'; + private MockInterface $mockExtension; private MockInterface $mockHttpClient; private MockInterface $mockApiClient; @@ -24,11 +28,11 @@ class PerfbaseIntegrationTest extends BaseTest protected function setUp(): void { parent::setUp(); - + $this->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', @@ -48,33 +52,29 @@ protected function tearDown(): void */ public function testCompleteProfilingWorkflow(): void { - // Setup extension expectations $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); $this->mockExtension->shouldReceive('startSpan')->once()->with('integration-span', $this->config->flags, []); $this->mockExtension->shouldReceive('stopSpan')->once()->with('integration-span'); - $this->mockExtension->shouldReceive('getSpanData')->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 + $this->mockExtension->shouldReceive('getSpanData')->twice()->andReturn(self::EXTENSION_JSON); + $this->mockExtension->shouldReceive('reset')->twice(); + + $this->mockApiClient->shouldReceive('submitTrace') + ->once() + ->with(Mockery::on(function (string $p): bool { + return strpos($p, '"v":1') !== false; + })) + ->andReturn(SubmitResult::success(202)); + $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 + $this->assertTrue($perfbase->stopTraceSpan('integration-span')); + $traceData = $perfbase->getTraceData(); - $this->assertEquals('integration-trace-data', $traceData); - - // Submit trace - $perfbase->submitTrace(); + $this->assertEquals(self::EXTENSION_JSON, $traceData); + + $result = $perfbase->submitTrace(); + $this->assertTrue($result->isSuccess()); } /** @@ -84,35 +84,25 @@ public function testCompleteProfilingWorkflow(): void public function testMultipleSpansWorkflow(): void { $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); - - // First span $this->mockExtension->shouldReceive('startSpan')->once()->with('span-1', $this->config->flags, []); $this->mockExtension->shouldReceive('stopSpan')->once()->with('span-1'); - - // Second span $this->mockExtension->shouldReceive('startSpan')->once()->with('span-2', $this->config->flags, []); $this->mockExtension->shouldReceive('stopSpan')->once()->with('span-2'); - - // Data retrieval and submission - $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn('multi-span-data'); - $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor - $this->mockApiClient->shouldReceive('submitTrace')->once()->with('multi-span-data'); - + $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn(self::EXTENSION_JSON); + $this->mockExtension->shouldReceive('reset')->twice(); + + $this->mockApiClient->shouldReceive('submitTrace') + ->once() + ->andReturn(SubmitResult::success()); + $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(); + $this->assertTrue($perfbase->stopTraceSpan('span-2')); + $this->assertTrue($perfbase->stopTraceSpan('span-1')); + + $this->assertTrue($perfbase->submitTrace()->isSuccess()); } /** @@ -122,38 +112,28 @@ public function testMultipleSpansWorkflow(): void public function testWorkflowWithConfigurationChanges(): void { $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); - - // Initial span with default flags $this->mockExtension->shouldReceive('startSpan')->once()->with('config-span', $this->config->flags, []); - - // After flag change + $newFlags = 2048; $this->mockExtension->shouldReceive('startSpan')->once()->with('modified-span', $newFlags, []); $this->mockExtension->shouldReceive('stopSpan')->once()->with('config-span'); $this->mockExtension->shouldReceive('stopSpan')->once()->with('modified-span'); - $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn('config-change-data'); - $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor - $this->mockApiClient->shouldReceive('submitTrace')->once()->with('config-change-data'); - + $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn(self::EXTENSION_JSON); + $this->mockExtension->shouldReceive('reset')->twice(); + + $this->mockApiClient->shouldReceive('submitTrace') + ->once() + ->andReturn(SubmitResult::success()); + $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 + + $this->assertTrue($perfbase->submitTrace()->isSuccess()); } /** @@ -164,48 +144,40 @@ public function testErrorHandlingInWorkflow(): void { $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); $this->mockExtension->shouldReceive('startSpan')->once()->with('error-span', $this->config->flags, []); - $this->mockExtension->shouldReceive('reset')->once(); // Called by destructor - + $this->mockExtension->shouldReceive('reset')->once(); + $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->assertFalse($perfbase->stopTraceSpan('non-existent-span')); + $this->mockExtension->shouldReceive('stopSpan')->once()->with('error-span'); - $result = $perfbase->stopTraceSpan('error-span'); - $this->assertTrue($result); + $this->assertTrue($perfbase->stopTraceSpan('error-span')); } /** - * Test ApiClient integration with real HTTP client interface + * Test ApiClient integration with HttpClientInterface * @covers \Perfbase\SDK\Http\ApiClient */ public function testApiClientIntegration(): void { - $testData = 'api-integration-data'; - + $testData = '{"v":1,"p":"test","d":"2024-01-01T00:00:00Z"}'; + $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'; - })); - + })) + ->andReturn(SubmitResult::success(202)); + $apiClient = new ApiClient($this->config, $this->mockHttpClient); - - $apiClient->submitTrace($testData); - - $this->assertTrue(true); // Verify no exception was thrown + $this->assertTrue($apiClient->submitTrace($testData)->isSuccess()); } /** - * Test full stack integration with API client + * Test full stack integration: Perfbase → TracePayloadFactory → ApiClient → HttpClient * @covers \Perfbase\SDK\Perfbase */ public function testFullStackIntegration(): void @@ -213,27 +185,55 @@ public function testFullStackIntegration(): void $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); $this->mockExtension->shouldReceive('startSpan')->once()->with('full-stack', $this->config->flags, []); $this->mockExtension->shouldReceive('stopSpan')->once()->with('full-stack'); - $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn('full-stack-data'); - $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor - + $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn(self::EXTENSION_JSON); + $this->mockExtension->shouldReceive('reset')->twice(); + $this->mockHttpClient->shouldReceive('post') ->once() ->with('/v1/submit', Mockery::on(function ($options) { - return $options['body'] === 'full-stack-data' + $body = json_decode($options['body'], true); + // Factory should have injected 'd' timestamp + return is_array($body) + && $body['v'] === 1 + && $body['p'] === 'dGVzdA==' + && isset($body['d']) && isset($options['headers']['Authorization']); - })); - - // Create real API client with mocked HTTP client + })) + ->andReturn(SubmitResult::success(202)); + $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 + $this->assertTrue($perfbase->submitTrace()->isSuccess()); + } + + /** + * Test that failed submission preserves trace state + * @covers \Perfbase\SDK\Perfbase + */ + public function testFailedSubmissionPreservesState(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('startSpan')->once(); + $this->mockExtension->shouldReceive('stopSpan')->once(); + $this->mockExtension->shouldReceive('getSpanData')->andReturn(self::EXTENSION_JSON); + $this->mockExtension->shouldReceive('reset')->once(); // destructor only + + $this->mockHttpClient->shouldReceive('post') + ->once() + ->andReturn(SubmitResult::retryableFailure(503, 'Service Unavailable')); + + $apiClient = new ApiClient($this->config, $this->mockHttpClient); + $perfbase = new Perfbase($this->config, $this->mockExtension, $apiClient); + + $perfbase->startTraceSpan('failing-span'); + $perfbase->stopTraceSpan('failing-span'); + $result = $perfbase->submitTrace(); + + $this->assertTrue($result->isRetryable()); + $this->assertSame(503, $result->getStatusCode()); } /** @@ -244,20 +244,16 @@ public function testCleanupBehavior(): void { $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); $this->mockExtension->shouldReceive('startSpan')->once()->with('cleanup-span', $this->config->flags, []); - $this->mockExtension->shouldReceive('reset')->twice(); // Once manual, once destructor - + $this->mockExtension->shouldReceive('reset')->twice(); + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); - + $perfbase->startTraceSpan('cleanup-span'); - - // Manual reset $perfbase->reset(); - - // Verify active spans are cleared + $activeSpanNames = $this->getPrivateFieldValue($perfbase, 'activeSpanNames'); $this->assertEmpty($activeSpanNames); - - // 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 index 37d7d28..52cd3c5 100644 --- a/tests/PerfbaseTest.php +++ b/tests/PerfbaseTest.php @@ -5,11 +5,13 @@ use Mockery; use Mockery\MockInterface; use Perfbase\SDK\Config; +use Perfbase\SDK\Exception\PerfbaseException; use Perfbase\SDK\Exception\PerfbaseExtensionException; use Perfbase\SDK\Exception\PerfbaseInvalidSpanException; use Perfbase\SDK\Extension\ExtensionInterface; use Perfbase\SDK\Http\ApiClient; use Perfbase\SDK\Perfbase; +use Perfbase\SDK\SubmitResult; /** * @coversDefaultClass \Perfbase\SDK\Perfbase @@ -216,18 +218,52 @@ public function testGetTraceData(): void * @covers ::submitTrace * @covers ::reset */ - public function testSubmitTrace(): void + public function testSubmitTraceResetsOnSuccess(): void { + $extensionJson = json_encode(['v' => 1, 'p' => 'dGVzdA==']); $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); - $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn('trace-data'); + $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn($extensionJson); $this->mockExtension->shouldReceive('reset')->twice(); // Called by submitTrace and destructor - $this->mockApiClient->shouldReceive('submitTrace')->once()->with('trace-data'); + $this->mockApiClient->shouldReceive('submitTrace') + ->once() + ->with(Mockery::on(function (string $payload) { + $decoded = json_decode($payload, true); + return is_array($decoded) + && $decoded['v'] === 1 + && $decoded['p'] === 'dGVzdA==' + && isset($decoded['d']); // timestamp injected + })) + ->andReturn(SubmitResult::success(202)); $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + $result = $perfbase->submitTrace(); - $perfbase->submitTrace(); + $this->assertTrue($result->isSuccess()); + } + + /** + * @covers ::submitTrace + */ + public function testSubmitTraceDoesNotResetOnFailure(): void + { + $extensionJson = json_encode(['v' => 1, 'p' => 'dGVzdA==']); + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('startSpan')->once(); + $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn($extensionJson); + $this->mockExtension->shouldReceive('reset')->once(); // Only destructor, NOT submitTrace + $this->mockApiClient->shouldReceive('submitTrace') + ->once() + ->andReturn(SubmitResult::retryableFailure(503, 'Service Unavailable')); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + $perfbase->startTraceSpan('test-span'); + $result = $perfbase->submitTrace(); + + $this->assertTrue($result->isRetryable()); - $this->assertTrue(true); // Verify submitTrace completed successfully + // Verify span state is preserved + $activeSpanNames = $this->getPrivateFieldValue($perfbase, 'activeSpanNames'); + $this->assertContains('test-span', $activeSpanNames); } /** @@ -277,4 +313,115 @@ public function testDestructorCallsReset(): void $this->assertTrue(true); // Verify destructor was called without issues } + + /** + * @covers ::submitTrace + */ + public function testSubmitTraceThrowsWhenExtensionReturnsMalformedData(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn('not valid json'); + $this->mockExtension->shouldReceive('reset')->once(); // destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $this->expectException(PerfbaseException::class); + $this->expectExceptionMessage('invalid JSON'); + $perfbase->submitTrace(); + } + + /** + * @covers ::submitTrace + */ + public function testSubmitTraceThrowsWhenExtensionReturnsEmpty(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('getSpanData')->once()->andReturn(''); + $this->mockExtension->shouldReceive('reset')->once(); // destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + + $this->expectException(PerfbaseException::class); + $this->expectExceptionMessage('empty trace data'); + $perfbase->submitTrace(); + } + + /** + * @covers ::setAttribute + */ + public function testSetAttribute(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('setAttribute')->once()->with('test_key', 'test_value'); + $this->mockExtension->shouldReceive('reset')->once(); // destructor + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + $perfbase->setAttribute('test_key', 'test_value'); + + // Mockery will verify setAttribute was called with the correct arguments + $this->assertTrue(true); + } + + /** + * @covers ::startTraceSpan + */ + public function testStartTraceSpanWithAttributes(): void + { + $attrs = ['key1' => 'val1', 'key2' => 'val2']; + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('startSpan')->once()->with('my-span', $this->config->flags, $attrs); + $this->mockExtension->shouldReceive('reset')->once(); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + $perfbase->startTraceSpan('my-span', $attrs); + + $activeSpanNames = $this->getPrivateFieldValue($perfbase, 'activeSpanNames'); + $this->assertContains('my-span', $activeSpanNames); + } + + /** + * @covers ::stopTraceSpan + */ + public function testStopTraceSpanWithEmptyNameUsesDefault(): void + { + $this->mockExtension->shouldReceive('isAvailable')->once()->andReturn(true); + $this->mockExtension->shouldReceive('startSpan')->once()->with('default', $this->config->flags, []); + $this->mockExtension->shouldReceive('stopSpan')->once()->with('default'); + $this->mockExtension->shouldReceive('reset')->once(); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + $perfbase->startTraceSpan(''); + $result = $perfbase->stopTraceSpan(''); + + $this->assertTrue($result); + } + + /** + * @covers ::isAvailable + */ + public function testIsAvailableStaticReturnsTrueWhenExtensionLoaded(): void + { + $result = Perfbase::isAvailable(); + + if (\extension_loaded('perfbase')) { + $this->assertTrue($result); + } else { + $this->assertFalse($result); + } + } + + /** + * @covers ::isExtensionAvailable + */ + public function testIsExtensionAvailableReturnsFalse(): void + { + $this->mockExtension->shouldReceive('isAvailable') + ->once()->andReturn(true) // constructor + ->shouldReceive('isAvailable') + ->once()->andReturn(false); // isExtensionAvailable call + $this->mockExtension->shouldReceive('reset')->once(); + + $perfbase = new Perfbase($this->config, $this->mockExtension, $this->mockApiClient); + $this->assertFalse($perfbase->isExtensionAvailable()); + } } diff --git a/tests/SubmitResultTest.php b/tests/SubmitResultTest.php new file mode 100644 index 0000000..03058e1 --- /dev/null +++ b/tests/SubmitResultTest.php @@ -0,0 +1,140 @@ +assertTrue($result->isSuccess()); + $this->assertFalse($result->isRetryable()); + $this->assertFalse($result->isPermanentFailure()); + $this->assertSame(SubmitResult::STATUS_SUCCESS, $result->getStatus()); + $this->assertSame(202, $result->getStatusCode()); + $this->assertSame('Accepted', $result->getMessage()); + } + + /** + * @covers ::success + */ + public function testSuccessDefaultValues(): void + { + $result = SubmitResult::success(); + + $this->assertTrue($result->isSuccess()); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame('', $result->getMessage()); + } + + /** + * @covers ::retryableFailure + * @covers ::isRetryable + */ + public function testRetryableFailure(): void + { + $result = SubmitResult::retryableFailure(503, 'Service Unavailable'); + + $this->assertFalse($result->isSuccess()); + $this->assertTrue($result->isRetryable()); + $this->assertFalse($result->isPermanentFailure()); + $this->assertSame(503, $result->getStatusCode()); + $this->assertSame('Service Unavailable', $result->getMessage()); + } + + /** + * @covers ::retryableFailure + */ + public function testRetryableFailureWithNullStatusCode(): void + { + $result = SubmitResult::retryableFailure(null, 'Connection refused'); + + $this->assertTrue($result->isRetryable()); + $this->assertNull($result->getStatusCode()); + } + + /** + * @covers ::permanentFailure + * @covers ::isPermanentFailure + */ + public function testPermanentFailure(): void + { + $result = SubmitResult::permanentFailure(401, 'Unauthorized'); + + $this->assertFalse($result->isSuccess()); + $this->assertFalse($result->isRetryable()); + $this->assertTrue($result->isPermanentFailure()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('Unauthorized', $result->getMessage()); + } + + /** + * @covers ::retryableFailure + */ + public function testRetryableFailureDefaultValues(): void + { + $result = SubmitResult::retryableFailure(); + + $this->assertTrue($result->isRetryable()); + $this->assertNull($result->getStatusCode()); + $this->assertSame('', $result->getMessage()); + } + + /** + * @covers ::permanentFailure + */ + public function testPermanentFailureDefaultValues(): void + { + $result = SubmitResult::permanentFailure(); + + $this->assertTrue($result->isPermanentFailure()); + $this->assertNull($result->getStatusCode()); + $this->assertSame('', $result->getMessage()); + } + + /** + * @covers ::isSuccess + * @covers ::isRetryable + * @covers ::isPermanentFailure + */ + public function testStatusMethodsMutuallyExclusive(): void + { + $success = SubmitResult::success(); + $this->assertTrue($success->isSuccess()); + $this->assertFalse($success->isRetryable()); + $this->assertFalse($success->isPermanentFailure()); + + $retryable = SubmitResult::retryableFailure(); + $this->assertFalse($retryable->isSuccess()); + $this->assertTrue($retryable->isRetryable()); + $this->assertFalse($retryable->isPermanentFailure()); + + $permanent = SubmitResult::permanentFailure(); + $this->assertFalse($permanent->isSuccess()); + $this->assertFalse($permanent->isRetryable()); + $this->assertTrue($permanent->isPermanentFailure()); + } + + /** + * @covers ::getStatus + */ + public function testGetStatusReturnsCorrectConstants(): void + { + $this->assertSame(SubmitResult::STATUS_SUCCESS, SubmitResult::success()->getStatus()); + $this->assertSame(SubmitResult::STATUS_RETRYABLE_FAILURE, SubmitResult::retryableFailure()->getStatus()); + $this->assertSame(SubmitResult::STATUS_PERMANENT_FAILURE, SubmitResult::permanentFailure()->getStatus()); + } +} diff --git a/tests/TracePayloadFactoryTest.php b/tests/TracePayloadFactoryTest.php new file mode 100644 index 0000000..02dba71 --- /dev/null +++ b/tests/TracePayloadFactoryTest.php @@ -0,0 +1,96 @@ + 1, 'p' => 'dGVzdA==']); + + $output = TracePayloadFactory::build($input); + $decoded = json_decode($output, true); + + $this->assertSame(1, $decoded['v']); + $this->assertSame('dGVzdA==', $decoded['p']); + $this->assertArrayHasKey('d', $decoded); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $decoded['d']); + } + + /** + * @covers ::build + */ + public function testBuildPreservesExistingFields(): void + { + $input = json_encode(['v' => 1, 'p' => 'abc123']); + + $decoded = json_decode(TracePayloadFactory::build($input), true); + + $this->assertSame(1, $decoded['v']); + $this->assertSame('abc123', $decoded['p']); + } + + /** + * @covers ::build + */ + public function testBuildThrowsOnEmptyInput(): void + { + $this->expectException(PerfbaseException::class); + $this->expectExceptionMessage('empty trace data'); + + TracePayloadFactory::build(''); + } + + /** + * @covers ::build + */ + public function testBuildThrowsOnInvalidJson(): void + { + $this->expectException(PerfbaseException::class); + $this->expectExceptionMessage('invalid JSON'); + + TracePayloadFactory::build('not json'); + } + + /** + * @covers ::build + */ + public function testBuildThrowsOnMissingVersion(): void + { + $this->expectException(PerfbaseException::class); + $this->expectExceptionMessage('version field'); + + TracePayloadFactory::build(json_encode(['p' => 'abc'])); + } + + /** + * @covers ::build + */ + public function testBuildThrowsOnMissingPayload(): void + { + $this->expectException(PerfbaseException::class); + $this->expectExceptionMessage('payload field'); + + TracePayloadFactory::build(json_encode(['v' => 1])); + } + + /** + * @covers ::build + */ + public function testBuildOutputIsValidJson(): void + { + $input = json_encode(['v' => 1, 'p' => 'dGVzdA==']); + $output = TracePayloadFactory::build($input); + + $this->assertJson($output); + } +}