461 lines
15 KiB
PHP
461 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Api\ApiException;
|
|
use App\Framework\Api\ApiRequestTrait;
|
|
use App\Framework\Http\Headers;
|
|
use App\Framework\Http\Method;
|
|
use App\Framework\Http\Status;
|
|
use App\Framework\HttpClient\ClientOptions;
|
|
use App\Framework\HttpClient\ClientRequest;
|
|
use App\Framework\HttpClient\ClientResponse;
|
|
use App\Framework\HttpClient\HttpClient;
|
|
|
|
// Test class that uses the trait
|
|
final class TestApiClient
|
|
{
|
|
use ApiRequestTrait;
|
|
|
|
public function __construct(
|
|
string $baseUrl,
|
|
HttpClient $httpClient,
|
|
ClientOptions $defaultOptions
|
|
) {
|
|
$this->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);
|
|
});
|
|
});
|
|
});
|