baseUrl = $baseUrl; $this->httpClient = $httpClient; $this->defaultOptions = $defaultOptions; } // Expose protected method for testing public function testDecodeJson(ClientResponse $response): array { return $this->decodeJson($response); } } describe('ApiRequestTrait', function () { beforeEach(function () { $this->httpClient = Mockery::mock(HttpClient::class); $this->defaultOptions = ClientOptions::withTimeout(30); $this->baseUrl = 'https://api.example.com'; $this->apiClient = new TestApiClient( $this->baseUrl, $this->httpClient, $this->defaultOptions ); }); afterEach(function () { Mockery::close(); }); describe('sendRequest', function () { it('sends GET request successfully', function () { $expectedResponse = new ClientResponse( status: Status::OK, headers: new Headers([]), body: '{"data": "success"}' ); $this->httpClient ->shouldReceive('send') ->once() ->with(Mockery::on(function ($request) { return $request instanceof ClientRequest && $request->method === Method::GET && str_contains($request->url, '/api/users'); })) ->andReturn($expectedResponse); $response = $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/users', data: [] ); expect($response)->toBe($expectedResponse); expect($response->status)->toBe(Status::OK); }); it('sends POST request with data', function () { $expectedResponse = new ClientResponse( status: Status::CREATED, headers: new Headers([]), body: '{"id": 123}' ); $this->httpClient ->shouldReceive('send') ->once() ->with(Mockery::on(function ($request) { return $request instanceof ClientRequest && $request->method === Method::POST && str_contains($request->url, '/api/users') && !empty($request->body); // Data is stored in body as JSON })) ->andReturn($expectedResponse); $response = $this->apiClient->sendRequest( method: Method::POST, endpoint: '/api/users', data: ['name' => 'John Doe', 'email' => 'john@example.com'] ); expect($response->status)->toBe(Status::CREATED); }); it('strips leading slash from endpoint', function () { $expectedResponse = new ClientResponse( status: Status::OK, headers: new Headers([]), body: '{}' ); $this->httpClient ->shouldReceive('send') ->once() ->with(Mockery::on(function ($request) { // URL should be clean without double slashes return $request->url === 'https://api.example.com/api/test'; })) ->andReturn($expectedResponse); $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); }); it('handles endpoint without leading slash', function () { $expectedResponse = new ClientResponse( status: Status::OK, headers: new Headers([]), body: '{}' ); $this->httpClient ->shouldReceive('send') ->once() ->with(Mockery::on(function ($request) { return $request->url === 'https://api.example.com/api/test'; })) ->andReturn($expectedResponse); $this->apiClient->sendRequest( method: Method::GET, endpoint: 'api/test' ); }); it('uses custom options when provided', function () { $customOptions = ClientOptions::withTimeout(60); $expectedResponse = new ClientResponse( status: Status::OK, headers: new Headers([]), body: '{}' ); $this->httpClient ->shouldReceive('send') ->once() ->with(Mockery::on(function ($request) use ($customOptions) { return $request->options === $customOptions; })) ->andReturn($expectedResponse); $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test', data: [], options: $customOptions ); }); it('uses default options when none provided', function () { $expectedResponse = new ClientResponse( status: Status::OK, headers: new Headers([]), body: '{}' ); $this->httpClient ->shouldReceive('send') ->once() ->with(Mockery::on(function ($request) { return $request->options === $this->defaultOptions; })) ->andReturn($expectedResponse); $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); }); }); describe('error handling', function () { it('throws ApiException for 400 errors', function () { $errorResponse = new ClientResponse( status: Status::BAD_REQUEST, headers: new Headers([]), body: '{"error": "Invalid request"}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($errorResponse); $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); })->throws(ApiException::class); it('throws ApiException for 401 errors', function () { $errorResponse = new ClientResponse( status: Status::UNAUTHORIZED, headers: new Headers([]), body: '{"error": "Unauthorized"}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($errorResponse); $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); })->throws(ApiException::class); it('throws ApiException for 404 errors', function () { $errorResponse = new ClientResponse( status: Status::NOT_FOUND, headers: new Headers([]), body: '{"error": "Not found"}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($errorResponse); $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); })->throws(ApiException::class); it('throws ApiException for 500 errors', function () { $errorResponse = new ClientResponse( status: Status::INTERNAL_SERVER_ERROR, headers: new Headers([]), body: '{"error": "Server error"}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($errorResponse); $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); })->throws(ApiException::class); it('formats error message with detail field', function () { $errorResponse = new ClientResponse( status: Status::BAD_REQUEST, headers: new Headers([]), body: '{"detail": "Validation failed"}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($errorResponse); try { $this->apiClient->sendRequest( method: Method::POST, endpoint: '/api/users' ); expect(false)->toBeTrue(); // Should not reach here } catch (ApiException $e) { expect($e->getMessage())->toContain('Validation failed'); } }); it('formats error message with validation_messages', function () { $errorResponse = new ClientResponse( status: Status::UNPROCESSABLE_ENTITY, headers: new Headers([]), body: '{"detail": "Validation failed", "validation_messages": {"email": "Invalid format"}}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($errorResponse); try { $this->apiClient->sendRequest( method: Method::POST, endpoint: '/api/users' ); expect(false)->toBeTrue(); // Should not reach here } catch (ApiException $e) { expect($e->getMessage())->toContain('Validation failed'); expect($e->getMessage())->toContain('Validierungsfehler'); expect($e->getMessage())->toContain('email'); } }); it('formats error message with error field', function () { $errorResponse = new ClientResponse( status: Status::INTERNAL_SERVER_ERROR, headers: new Headers([]), body: '{"error": "Database connection failed"}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($errorResponse); try { $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); expect(false)->toBeTrue(); // Should not reach here } catch (ApiException $e) { expect($e->getMessage())->toContain('Database connection failed'); } }); it('formats generic error message when no error fields present', function () { $errorResponse = new ClientResponse( status: Status::BAD_REQUEST, headers: new Headers([]), body: '{"some_field": "some_value"}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($errorResponse); try { $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); expect(false)->toBeTrue(); // Should not reach here } catch (ApiException $e) { expect($e->getMessage())->toBe('API-Fehler'); } }); it('does not throw exception for 2xx responses', function () { $successResponse = new ClientResponse( status: Status::OK, headers: new Headers([]), body: '{"success": true}' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($successResponse); $response = $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); expect($response->status)->toBe(Status::OK); }); it('does not throw exception for 3xx responses', function () { $redirectResponse = new ClientResponse( status: Status::MOVED_PERMANENTLY, headers: new Headers(['Location' => 'https://example.com/new']), body: '' ); $this->httpClient ->shouldReceive('send') ->once() ->andReturn($redirectResponse); $response = $this->apiClient->sendRequest( method: Method::GET, endpoint: '/api/test' ); expect($response->status)->toBe(Status::MOVED_PERMANENTLY); }); }); describe('decodeJson', function () { it('decodes valid JSON response', function () { $response = new ClientResponse( status: Status::OK, headers: new Headers([]), body: '{"name": "John", "age": 30}' ); $data = $this->apiClient->testDecodeJson($response); expect($data)->toBeArray(); expect($data['name'])->toBe('John'); expect($data['age'])->toBe(30); }); it('returns empty array for invalid JSON', function () { $response = new ClientResponse( status: Status::OK, headers: new Headers([]), body: 'Invalid JSON {' ); $data = $this->apiClient->testDecodeJson($response); expect($data)->toBe([]); }); it('returns empty array for empty body', function () { $response = new ClientResponse( status: Status::NO_CONTENT, headers: new Headers([]), body: '' ); $data = $this->apiClient->testDecodeJson($response); expect($data)->toBe([]); }); it('decodes nested JSON structures', function () { $response = new ClientResponse( status: Status::OK, headers: new Headers([]), body: '{"user": {"name": "John", "email": "john@example.com"}, "meta": {"total": 1}}' ); $data = $this->apiClient->testDecodeJson($response); expect($data)->toBeArray(); expect($data['user'])->toBeArray(); expect($data['user']['name'])->toBe('John'); expect($data['meta']['total'])->toBe(1); }); }); });