diff --git a/src/Adapter/CurlAdapter.php b/src/Adapter/CurlAdapter.php index b3be7f7..9cc0aa0 100644 --- a/src/Adapter/CurlAdapter.php +++ b/src/Adapter/CurlAdapter.php @@ -4,38 +4,94 @@ namespace LapostaApi\Adapter; +/** + * Adapter class for PHP's cURL functions. + * + * This class provides a simple wrapper around PHP's native cURL functions + * to allow for easier testing through mocking of HTTP requests in the application. + */ class CurlAdapter { + /** + * Initialize a new cURL session. + * + * @return \CurlHandle|false A cURL handle on success, false on failure + */ public function init(): \CurlHandle|false { return curl_init(); } + /** + * Set an option for a cURL transfer. + * + * @param \CurlHandle $handle The cURL handle + * @param int $option The CURLOPT option to set + * @param mixed $value The value to set the option to + * + * @return bool Returns true on success, false on failure + */ public function setopt(\CurlHandle $handle, int $option, mixed $value): bool { return curl_setopt($handle, $option, $value); } + /** + * Execute the cURL request. + * + * @param \CurlHandle $handle The cURL handle + * + * @return string|false Returns the result of the request as string on success, false on failure + */ public function exec(\CurlHandle $handle): string|false { return curl_exec($handle); } + /** + * Get information about the last transfer. + * + * @param \CurlHandle $handle The cURL handle + * @param int $option The CURLINFO option to get, or 0 to get all info + * + * @return mixed The requested information or an array with all information if no option specified + */ public function getInfo(\CurlHandle $handle, int $option = 0): mixed { return curl_getinfo($handle, $option); } + /** + * Get the error code for the last cURL operation. + * + * @param \CurlHandle $handle The cURL handle + * + * @return int The error number or 0 if no error occurred + */ public function getErrno(\CurlHandle $handle): int { return curl_errno($handle); } + /** + * Get the error message for the last cURL operation. + * + * @param \CurlHandle $handle The cURL handle + * + * @return string The error message or an empty string if no error occurred + */ public function getError(\CurlHandle $handle): string { return curl_error($handle); } + /** + * Close a cURL session and free all resources. + * + * @param \CurlHandle $handle The cURL handle + * + * @return void + */ public function close(\CurlHandle $handle): void { curl_close($handle); diff --git a/src/Adapter/StreamAdapter.php b/src/Adapter/StreamAdapter.php index 19b6c97..237631b 100644 --- a/src/Adapter/StreamAdapter.php +++ b/src/Adapter/StreamAdapter.php @@ -4,63 +4,158 @@ namespace LapostaApi\Adapter; +/** + * Adapter class for PHP's file stream functions. + * + * This class provides a simple wrapper around PHP's native stream functions + * to allow for easier testing through mocking of file operations in the application. + */ class StreamAdapter { + /** + * Opens a file or URL. + * + * @param string $filename The filename or URL to open + * @param string $mode The mode for opening the file + * + * @return resource|false A file pointer resource on success, or false on failure + */ public function fopen(string $filename, string $mode) { return fopen($filename, $mode); } + /** + * Checks if a variable is a resource. + * + * @param mixed $value The value to check + * + * @return bool True if the value is a resource, false otherwise + */ public function isResource(mixed $value): bool { return is_resource($value); } + /** + * Reads up to a specified number of bytes from a stream. + * + * @param resource $stream The file pointer resource + * @param int $length The number of bytes to read + * + * @return string|false The read string or false on failure + */ public function fread($stream, int $length): string|false { return fread($stream, $length); } + /** + * Writes to a stream. + * + * @param resource $stream The file pointer resource + * @param string $string The string to write + * + * @return int|false The number of bytes written or false on failure + */ public function fwrite($stream, string $string): int|false { return fwrite($stream, $string); } + /** + * Gets information about a file using an open file pointer. + * + * @param resource $stream The file pointer resource + * + * @return array|false An array with file statistics or false on failure + */ public function fstat($stream): array|false { return fstat($stream); } + /** + * Gets the current position of the file pointer. + * + * @param resource $stream The file pointer resource + * + * @return int|false The position of the file pointer or false on error + */ public function ftell($stream): int|false { return ftell($stream); } + /** + * Tests for end-of-file on a file pointer. + * + * @param resource $stream The file pointer resource + * + * @return bool True if the file pointer is at EOF or an error occurs, false otherwise + */ public function feof($stream): bool { return feof($stream); } + /** + * Seeks on a file pointer. + * + * @param resource $stream The file pointer resource + * @param int $offset The offset in bytes + * @param int $whence The position from where to start seeking + * + * @return int Returns 0 on success, -1 on failure + */ public function fseek($stream, int $offset, int $whence = SEEK_SET): int { return fseek($stream, $offset, $whence); } + /** + * Rewinds a file pointer to the beginning. + * + * @param resource $stream The file pointer resource + * + * @return bool True on success, false on failure + */ public function rewind($stream): bool { return rewind($stream); } + /** + * Closes an open file pointer. + * + * @param resource $stream The file pointer resource + * + * @return bool True on success, false on failure + */ public function fclose($stream): bool { return fclose($stream); } + /** + * Reads the remaining contents from a stream into a string. + * + * @param resource $stream The file pointer resource + * + * @return string|false The read data or false on failure + */ public function streamGetContents($stream): string|false { return stream_get_contents($stream); } + /** + * Retrieves header/meta data from streams/file pointers. + * + * @param resource $stream The file pointer resource + * + * @return array Array containing metadata + */ public function streamGetMetaData($stream): array { return stream_get_meta_data($stream); diff --git a/src/Api/BaseApi.php b/src/Api/BaseApi.php index 96432ad..bc38a24 100644 --- a/src/Api/BaseApi.php +++ b/src/Api/BaseApi.php @@ -17,7 +17,7 @@ abstract class BaseApi { /** - * Constructor method to initialize the Laposta instance and request factory. + * Constructor method to initialize the Laposta instance. * * @param Laposta $laposta An instance of the Laposta class. */ @@ -141,7 +141,7 @@ protected function createRequest( $formattedBody = $contentType->formatBody($body); // Create a stream and write the body to the stream - $stream = (new StreamFactory())->createStream(); + $stream = $this->laposta->getStreamFactory()->createStream(); $stream->write($formattedBody); $request = $request->withBody($stream); @@ -180,8 +180,39 @@ protected function handleResponse(RequestInterface $request, ResponseInterface $ // Check HTTP status code $statusCode = $response->getStatusCode(); if ($statusCode < 200 || $statusCode >= 300) { + $body = (string)$response->getBody(); + $errorMessage = sprintf('API request failed with status code %d', $statusCode); + + // Try to extract error details from the response body + try { + $responseData = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + if (isset($responseData['error']) && is_array($responseData['error'])) { + // Add code, type, parameter and message if available + if (isset($responseData['error']['code'])) { + $errorMessage .= sprintf(', error.code: %s', $responseData['error']['code']); + } + + if (isset($responseData['error']['type'])) { + $errorMessage .= sprintf(', error.type: %s', $responseData['error']['type']); + } + + if (isset($responseData['error']['parameter'])) { + $errorMessage .= sprintf(', error.parameter: %s', $responseData['error']['parameter']); + } + + if (isset($responseData['error']['message'])) { + $errorMessage .= sprintf(', error.message: %s', $responseData['error']['message']); + } + } + } catch (\JsonException $e) { + // If the response body is not valid JSON, add the raw body + if (!empty($body) && strlen($body) < 1000) { + $errorMessage .= sprintf(', response body: %s', $body); + } + } + throw new ApiException( - sprintf('API request failed with status code %d', $statusCode), + $errorMessage, $request, $response, ); @@ -209,7 +240,7 @@ protected function handleResponse(RequestInterface $request, ResponseInterface $ */ protected function getResource(): string { - // Extract resource name by removing namespace and suffix + // Extract the resource name by removing namespace and suffix return strtolower(str_replace(['LapostaApi\\Api\\', 'Api'], '', static::class)); } } diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php index 06b980d..9e2b18d 100644 --- a/src/Exception/ApiException.php +++ b/src/Exception/ApiException.php @@ -81,7 +81,7 @@ public function getResponseBody(): string /** * Returns the parsed JSON response (lazy loaded) */ - public function getResponseJson(): array + public function getResponseData(): array { if ($this->responseJson === null) { $body = $this->getResponseBody(); @@ -99,6 +99,40 @@ public function getResponseJson(): array return $this->responseJson; } + /** + * Returns the error type if available + */ + public function getErrorType(): ?string + { + return $this->getResponseData()['error']['type'] ?? null; + } + + /** + * Returns the error code if available + */ + public function getErrorCode(): ?int + { + $code = $this->getResponseData()['error']['code'] ?? null; + return $code !== null ? (int)$code : null; + } + + /** + * Returns the error parameter if available + */ + public function getErrorParameter(): ?string + { + return $this->getResponseData()['error']['parameter'] ?? null; + } + + /** + * Returns the error message if available + */ + public function getErrorMessage(): ?string + { + return $this->getResponseData()['error']['message'] ?? null; + } + + /** * Returns a readable representation of the exception */ @@ -106,6 +140,27 @@ public function __toString(): string { $str = parent::__toString(); $str .= "\nHTTP Status: " . $this->getHttpStatus(); + + $errorCode = $this->getErrorCode(); + if ($errorCode !== null) { + $str .= "\nError Code: " . $errorCode; + } + + $errorType = $this->getErrorType(); + if ($errorType !== null) { + $str .= "\nError Type: " . $errorType; + } + + $errorParameter = $this->getErrorParameter(); + if ($errorParameter !== null) { + $str .= "\nError Parameter: " . $errorParameter; + } + + $errorMessage = $this->getErrorMessage(); + if ($errorMessage !== null) { + $str .= "\nError Message: " . $errorMessage; + } + $str .= "\nResponse Body: " . $this->getResponseBody(); return $str; diff --git a/src/Http/Client.php b/src/Http/Client.php index b680b3b..dd57721 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -210,7 +210,7 @@ protected function splitResponseHeadersAndBody(string $content, int $headerSize) * @param int $status HTTP status code * @param string $rawHeaders Raw response headers * @param string $body Response body - * + * @param RequestInterface $request * @return ResponseInterface * @throws ClientException When response creation fails */ diff --git a/src/Http/Message.php b/src/Http/Message.php index 205fbff..082b25e 100644 --- a/src/Http/Message.php +++ b/src/Http/Message.php @@ -25,9 +25,7 @@ abstract class Message implements MessageInterface protected StreamInterface $body; /** - * Retrieve the HTTP protocol version. - * - * @return string + * {@inheritDoc} */ public function getProtocolVersion(): string { @@ -35,11 +33,7 @@ public function getProtocolVersion(): string } /** - * Return a new message with the specified protocol version. - * - * @param string $version - * - * @return MessageInterface + * {@inheritDoc} */ public function withProtocolVersion(string $version): MessageInterface { @@ -49,9 +43,7 @@ public function withProtocolVersion(string $version): MessageInterface } /** - * Retrieve all headers. - * - * @return array> + * {@inheritDoc} */ public function getHeaders(): array { @@ -59,11 +51,7 @@ public function getHeaders(): array } /** - * Check if a specific header exists. - * - * @param string $name - * - * @return bool + * {@inheritDoc} */ public function hasHeader(string $name): bool { @@ -72,11 +60,7 @@ public function hasHeader(string $name): bool } /** - * Retrieve a specific header in array format. - * - * @param string $name - * - * @return string[] + * {@inheritDoc} */ public function getHeader(string $name): array { @@ -85,11 +69,7 @@ public function getHeader(string $name): array } /** - * Retrieve a specific header as a single string (comma-separated). - * - * @param string $name - * - * @return string + * {@inheritDoc} */ public function getHeaderLine(string $name): string { @@ -101,35 +81,25 @@ public function getHeaderLine(string $name): string } /** - * Return a new message with the specified header (old values are overwritten). - * - * @param string $name - * @param string|string[] $value - * - * @return MessageInterface + * {@inheritDoc} */ public function withHeader(string $name, $value): MessageInterface { - $this->validateHeader($name, $value); // Validate the header name and value + $this->validateHeader($name, $value); $new = clone $this; - $normalized = $this->normalizeHeaderName($name); // Normalize the header name - $new->headers[$normalized] = (array)$value; // Store as an array + $normalized = $this->normalizeHeaderName($name); + $new->headers[$normalized] = (array)$value; return $new; } /** - * Return a new message with an added header (existing values are preserved). - * - * @param string $name - * @param string|string[] $value - * - * @return MessageInterface + * {@inheritDoc} */ public function withAddedHeader(string $name, $value): MessageInterface { - $this->validateHeader($name, $value); // Validate the header name and value + $this->validateHeader($name, $value); $new = clone $this; - $normalized = $this->normalizeHeaderName($name); // Normalize the header name + $normalized = $this->normalizeHeaderName($name); if (!isset($new->headers[$normalized])) { $new->headers[$normalized] = []; } @@ -138,24 +108,18 @@ public function withAddedHeader(string $name, $value): MessageInterface } /** - * Return a new message with a removed header. - * - * @param string $name - * - * @return MessageInterface + * {@inheritDoc} */ public function withoutHeader(string $name): MessageInterface { - $normalized = $this->normalizeHeaderName($name); // Normalize the header name + $normalized = $this->normalizeHeaderName($name); $new = clone $this; unset($new->headers[$normalized]); return $new; } /** - * Retrieve the body. - * - * @return StreamInterface + * {@inheritDoc} */ public function getBody(): StreamInterface { @@ -163,11 +127,7 @@ public function getBody(): StreamInterface } /** - * Return a new message with a new body object. - * - * @param StreamInterface $body - * - * @return MessageInterface + * {@inheritDoc} */ public function withBody(StreamInterface $body): MessageInterface { diff --git a/src/Http/Request.php b/src/Http/Request.php index 0b33c30..4dfa704 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -26,9 +26,8 @@ public function __construct( array $headers = [], protected ?string $requestTarget = null, ) { - $method = strtoupper($method); - $this->validateMethod($method); $this->method = strtoupper($method); + $this->validateMethod($this->method); // Set and normalize headers $normalizedHeaders = []; diff --git a/src/Http/RequestFactory.php b/src/Http/RequestFactory.php index 6e901ea..95737ce 100644 --- a/src/Http/RequestFactory.php +++ b/src/Http/RequestFactory.php @@ -12,20 +12,20 @@ class RequestFactory implements RequestFactoryInterface { + /** + * Creates a new RequestFactory instance. + * + * @param StreamFactoryInterface $streamFactory Factory to create request streams + * @param UriFactoryInterface $uriFactory Factory to create URIs + */ public function __construct( protected StreamFactoryInterface $streamFactory = new StreamFactory(), protected UriFactoryInterface $uriFactory = new UriFactory(), ) { } - /** - * Create a new HTTP request with optional extra headers. - * - * @param string $method The HTTP method (e.g., 'GET', 'POST'). - * @param UriInterface|string $uri The URI for the request as a string or UriInterface. - * - * @return RequestInterface + * {@inheritDoc} */ public function createRequest(string $method, $uri): RequestInterface { diff --git a/src/Http/Response.php b/src/Http/Response.php index a1ef08c..082433d 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -25,11 +25,17 @@ public function __construct( $this->reasonPhrase = $reasonPhrase ?: $this->getDefaultReasonPhrase($statusCode); } + /** + * {@inheritDoc} + */ public function getStatusCode(): int { return $this->statusCode; } + /** + * {@inheritDoc} + */ public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface { $new = clone $this; @@ -38,6 +44,9 @@ public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterf return $new; } + /** + * {@inheritDoc} + */ public function getReasonPhrase(): string { return $this->reasonPhrase; @@ -50,11 +59,10 @@ protected function getDefaultReasonPhrase(int $code): string 201 => 'Created', 400 => 'Bad Request', 401 => 'Unauthorized', - 403 => 'Forbidden', + 402 => 'Request Failed', 404 => 'Not Found', + 429 => 'Too Many Requests', 500 => 'Internal Server Error', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', default => '', }; } diff --git a/src/Http/ResponseFactory.php b/src/Http/ResponseFactory.php index eb2168a..a4d3b00 100644 --- a/src/Http/ResponseFactory.php +++ b/src/Http/ResponseFactory.php @@ -9,6 +9,9 @@ class ResponseFactory implements ResponseFactoryInterface { + /** + * {@inheritDoc} + */ public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface { return new Response($code, $reasonPhrase); diff --git a/src/Http/Stream.php b/src/Http/Stream.php index bac34b4..67ef715 100644 --- a/src/Http/Stream.php +++ b/src/Http/Stream.php @@ -14,6 +14,14 @@ class Stream implements StreamInterface protected StreamAdapter $adapter; + /** + * Initialize a new stream instance. + * + * @param resource $stream The PHP stream resource + * @param StreamAdapter $adapter The adapter for stream operations + * + * @throws RuntimeException When $stream is not a valid resource + */ public function __construct($stream, StreamAdapter $adapter) { $this->adapter = $adapter; @@ -25,6 +33,9 @@ public function __construct($stream, StreamAdapter $adapter) $this->stream = $stream; } + /** + * {@inheritDoc} + */ public function __toString(): string { if (!$this->isReadable() || !$this->isSeekable()) { @@ -35,6 +46,9 @@ public function __toString(): string return $this->adapter->streamGetContents($this->stream) ?: ''; } + /** + * {@inheritDoc} + */ public function close(): void { if (isset($this->stream)) { @@ -43,6 +57,9 @@ public function close(): void } } + /** + * {@inheritDoc} + */ public function detach() { $result = $this->stream; @@ -50,6 +67,9 @@ public function detach() return $result; } + /** + * {@inheritDoc} + */ public function getSize(): ?int { if (!$this->stream) { @@ -60,6 +80,9 @@ public function getSize(): ?int return $stats['size'] ?? null; } + /** + * {@inheritDoc} + */ public function tell(): int { if (!$this->stream) { @@ -75,17 +98,26 @@ public function tell(): int return $result; } + /** + * {@inheritDoc} + */ public function eof(): bool { return !$this->stream || $this->adapter->feof($this->stream); } + /** + * {@inheritDoc} + */ public function isSeekable(): bool { $meta = $this->getMetadata(); return $meta['seekable'] ?? false; } + /** + * {@inheritDoc} + */ public function seek($offset, $whence = SEEK_SET): void { if (!$this->isSeekable() || $this->adapter->fseek($this->stream, $offset, $whence) === -1) { @@ -93,6 +125,9 @@ public function seek($offset, $whence = SEEK_SET): void } } + /** + * {@inheritDoc} + */ public function rewind(): void { if (!$this->adapter->rewind($this->stream)) { @@ -100,12 +135,18 @@ public function rewind(): void } } + /** + * {@inheritDoc} + */ public function isWritable(): bool { $mode = $this->getMetadata('mode'); return is_string($mode) && (str_contains($mode, 'w') || str_contains($mode, 'a') || str_contains($mode, '+')); } + /** + * {@inheritDoc} + */ public function write($string): int { if (!$this->isWritable()) { @@ -121,12 +162,18 @@ public function write($string): int return $result; } + /** + * {@inheritDoc} + */ public function isReadable(): bool { $mode = $this->getMetadata('mode'); return is_string($mode) && (str_contains($mode, 'r') || str_contains($mode, '+')); } + /** + * {@inheritDoc} + */ public function read($length): string { if (!$this->isReadable()) { @@ -141,6 +188,9 @@ public function read($length): string return $result; } + /** + * {@inheritDoc} + */ public function getContents(): string { if (!$this->isReadable()) { @@ -155,6 +205,9 @@ public function getContents(): string return $contents; } + /** + * {@inheritDoc} + */ public function getMetadata($key = null): mixed { if (!$this->stream) { diff --git a/src/Http/StreamFactory.php b/src/Http/StreamFactory.php index ee4df41..a446317 100644 --- a/src/Http/StreamFactory.php +++ b/src/Http/StreamFactory.php @@ -18,27 +18,15 @@ class StreamFactory implements StreamFactoryInterface { /** - * Stream adapter used for creating stream resources + * Creates a new StreamFactory instance. */ - private StreamAdapter $adapter; - - /** - * Constructor - * - * @param StreamAdapter|null $adapter Optional stream adapter, creates new one if not provided - */ - public function __construct(?StreamAdapter $adapter = null) - { - $this->adapter = $adapter ?? new StreamAdapter(); + public function __construct( + protected StreamAdapter $adapter = new StreamAdapter() + ) { } /** - * Create a new stream with the given content - * - * @param string $content String content to be used as stream content - * - * @return StreamInterface A stream containing the specified content - * @throws RuntimeException If the temporary stream cannot be created + * {@inheritDoc} */ public function createStream(string $content = ''): StreamInterface { @@ -58,13 +46,7 @@ public function createStream(string $content = ''): StreamInterface } /** - * Create a stream from a file - NOT IMPLEMENTED - * - * @param string $filename Path to file - * @param string $mode Mode used to open the file - * - * @return StreamInterface - * @throws RuntimeException This method is not implemented + * {@inheritDoc} */ public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface { @@ -72,12 +54,7 @@ public function createStreamFromFile(string $filename, string $mode = 'r'): Stre } /** - * Create a new stream from an existing resource - NOT IMPLEMENTED - * - * @param resource $resource PHP resource to create stream from - * - * @return StreamInterface - * @throws RuntimeException This method is not implemented + * {@inheritDoc} */ public function createStreamFromResource($resource): StreamInterface { diff --git a/src/Http/Uri.php b/src/Http/Uri.php index b1973a3..f6f3752 100644 --- a/src/Http/Uri.php +++ b/src/Http/Uri.php @@ -14,6 +14,14 @@ class Uri implements UriInterface protected string $query = ''; protected string $fragment = ''; + /** + * Constructs a new Uri instance by parsing a URI string. + * + * Parses the URI string into its components using parse_url() and sets the corresponding + * instance properties. An empty URI string will result in default empty values. + * + * @param string $uri The URI string to parse. + */ public function __construct(string $uri = '') { $parts = parse_url($uri); @@ -30,11 +38,17 @@ public function __construct(string $uri = '') } } + /** + * {@inheritDoc} + */ public function getScheme(): string { return $this->scheme; } + /** + * {@inheritDoc} + */ public function getAuthority(): string { $authority = $this->host; @@ -49,36 +63,57 @@ public function getAuthority(): string return $authority; } + /** + * {@inheritDoc} + */ public function getUserInfo(): string { return $this->userInfo; } + /** + * {@inheritDoc} + */ public function getHost(): string { return $this->host; } + /** + * {@inheritDoc} + */ public function getPort(): ?int { return $this->port; } + /** + * {@inheritDoc} + */ public function getPath(): string { return $this->path; } + /** + * {@inheritDoc} + */ public function getQuery(): string { return $this->query; } + /** + * {@inheritDoc} + */ public function getFragment(): string { return $this->fragment; } + /** + * {@inheritDoc} + */ public function withScheme(string $scheme): UriInterface { $clone = clone $this; @@ -86,6 +121,9 @@ public function withScheme(string $scheme): UriInterface return $clone; } + /** + * {@inheritDoc} + */ public function withUserInfo(string $user, ?string $password = null): UriInterface { $clone = clone $this; @@ -93,6 +131,9 @@ public function withUserInfo(string $user, ?string $password = null): UriInterfa return $clone; } + /** + * {@inheritDoc} + */ public function withHost(string $host): UriInterface { $clone = clone $this; @@ -100,6 +141,9 @@ public function withHost(string $host): UriInterface return $clone; } + /** + * {@inheritDoc} + */ public function withPort(?int $port): UriInterface { $clone = clone $this; @@ -107,6 +151,9 @@ public function withPort(?int $port): UriInterface return $clone; } + /** + * {@inheritDoc} + */ public function withPath(string $path): UriInterface { $clone = clone $this; @@ -114,6 +161,9 @@ public function withPath(string $path): UriInterface return $clone; } + /** + * {@inheritDoc} + */ public function withQuery(string $query): UriInterface { $clone = clone $this; @@ -121,6 +171,9 @@ public function withQuery(string $query): UriInterface return $clone; } + /** + * {@inheritDoc} + */ public function withFragment(string $fragment): UriInterface { $clone = clone $this; @@ -128,6 +181,9 @@ public function withFragment(string $fragment): UriInterface return $clone; } + /** + * {@inheritDoc} + */ public function __toString(): string { $uri = ''; diff --git a/src/Http/UriFactory.php b/src/Http/UriFactory.php index 4087641..0827b53 100644 --- a/src/Http/UriFactory.php +++ b/src/Http/UriFactory.php @@ -9,6 +9,9 @@ class UriFactory implements UriFactoryInterface { + /** + * {@inheritDoc} + */ public function createUri(string $uri = ''): UriInterface { return new Uri($uri); diff --git a/src/Type/BulkMode.php b/src/Type/BulkMode.php index ceee5d2..55428e7 100644 --- a/src/Type/BulkMode.php +++ b/src/Type/BulkMode.php @@ -4,9 +4,26 @@ namespace LapostaApi\Type; +/** + * Enum representing the possible bulk operation modes. + * + * This enum is used to specify the behavior when performing bulk operations + * on resources in the Laposta API. + */ enum BulkMode: string { + /** + * Only add new records, skip existing ones. + */ case ADD = 'add'; + + /** + * Add new records and update existing ones. + */ case ADD_AND_EDIT = 'add_and_edit'; + + /** + * Only update existing records, skip new ones. + */ case EDIT = 'edit'; } diff --git a/src/Type/ContentType.php b/src/Type/ContentType.php index 8828bc6..d632453 100644 --- a/src/Type/ContentType.php +++ b/src/Type/ContentType.php @@ -4,20 +4,37 @@ namespace LapostaApi\Type; +/** + * Enum representing content types for HTTP requests. + * + * This enum defines supported content types used in API communication + * and provides functionality to format request bodies accordingly. + */ enum ContentType: string { + /** + * JSON content type for structured data. + * Used when sending JSON-formatted request bodies. + */ case JSON = 'application/json'; + + /** + * Form URL-encoded content type. + * Used when sending form data in request bodies. + */ case FORM = 'application/x-www-form-urlencoded'; /** - * Processes the body according to the content type + * Processes the body according to the content type. + * + * Formats the provided data array into a string representation + * matching the current content type. * * @param array $data The data to be formatted * * @return string The formatted body * @throws \JsonException If JSON encoding fails */ - public function formatBody(array $data): string { return match ($this) { diff --git a/standalone/autoload.php b/standalone/autoload.php index 735d566..07d177a 100644 --- a/standalone/autoload.php +++ b/standalone/autoload.php @@ -1,7 +1,18 @@ __DIR__ . '/../src/', 'Psr\\Http\\' => __DIR__ . '/Psr/Http/', @@ -11,16 +22,16 @@ foreach ($namespaces as $prefix => $baseDir) { // If the class starts with this namespace prefix if (str_starts_with($class, $prefix)) { - // Get the relative class path + // Get the relative class path by removing the namespace prefix $relativeClass = substr($class, strlen($prefix)); - // Build the full file path + // Build the full file path by converting namespace separators to directory separators $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php'; // Load the file if it exists if (file_exists($file)) { require $file; - return; // Stop after loading the file + return; } } } diff --git a/tests/Integration/Api/CampaignApiIntegrationTest.php b/tests/Integration/Api/CampaignApiIntegrationTest.php index 7a9c17e..7927f61 100644 --- a/tests/Integration/Api/CampaignApiIntegrationTest.php +++ b/tests/Integration/Api/CampaignApiIntegrationTest.php @@ -5,6 +5,7 @@ namespace LapostaApi\Tests\Integration\Api; use LapostaApi\Exception\ApiException; +use LapostaApi\Laposta; use LapostaApi\Tests\Integration\BaseIntegrationTestCase; class CampaignApiIntegrationTest extends BaseIntegrationTestCase @@ -36,11 +37,11 @@ protected function tearDown(): void } /** - * Maakt data voor een nieuwe campagne met de juiste parameters + * Creates data for a new campaign with the right parameters * - * @param array $customData Aangepaste data om de standaardwaarden te overschrijven + * @param array $customData Custom data to override the default values * - * @return array Data voor het aanmaken van een campagne + * @return array Data for creating a campaign */ protected function createCampaignData(array $customData = []): array { @@ -58,6 +59,34 @@ protected function createCampaignData(array $customData = []): array return array_merge($defaultData, $customData); } + /** + * Helper method to create a campaign with content for testing actions + * + * @return string The ID of the created campaign + */ + protected function createCampaignWithContent(): string + { + // Create a campaign + $campaignName = 'Content Test Campaign - ' . $this->generateRandomString(); + $createData = $this->createCampaignData([ + 'name' => $campaignName, + 'subject' => 'laposta-api-php - ' . Laposta::VERSION . ' - ' . date('Y-m-d H:i:s'), + ]); + + $createdCampaign = $this->laposta->campaignApi()->create($createData); + $campaignId = $createdCampaign['campaign']['campaign_id']; + + // Add content to the campaign + $contentData = [ + 'html' => '

Test Content

' + . '

This is a test email for integration testing.

', + ]; + + $this->laposta->campaignApi()->updateContent($campaignId, $contentData); + + return $campaignId; + } + public function testCreateCampaign(): void { $campaignName = 'Test Campaign - ' . $this->generateRandomString(); @@ -157,7 +186,7 @@ public function testGetAllCampaigns(): void ]); $createdCampaignResponse = $this->laposta->campaignApi()->create($createData); - $createdCampaignIdForThisTest = $createdCampaignResponse['campaign']['campaign_id']; + $this->createdCampaignId = $createdCampaignResponse['campaign']['campaign_id']; $response = $this->laposta->campaignApi()->all(); @@ -168,18 +197,89 @@ public function testGetAllCampaigns(): void $found = false; foreach ($response['data'] as $campaignEntry) { $this->assertArrayHasKey('campaign', $campaignEntry); - if ($campaignEntry['campaign']['campaign_id'] === $createdCampaignIdForThisTest) { + if ($campaignEntry['campaign']['campaign_id'] === $this->createdCampaignId) { $found = true; break; } } $this->assertTrue($found, 'Created campaign not found in all campaigns response.'); + } - // Ruim de campagne op die specifiek voor deze test is gemaakt - try { - $this->laposta->campaignApi()->delete($createdCampaignIdForThisTest); - } catch (ApiException $e) { - fwrite(STDERR, "Cleanup error (campaign): " . $e->getMessage() . "\n"); - } + public function testGetAndUpdateContent(): void + { + // Create a campaign + $campaignName = 'Content Test Campaign - ' . $this->generateRandomString(); + $createData = $this->createCampaignData([ + 'name' => $campaignName, + 'subject' => 'Content Test Subject', + ]); + + $createdCampaign = $this->laposta->campaignApi()->create($createData); + $this->createdCampaignId = $createdCampaign['campaign']['campaign_id']; + + // Add content to the campaign and verify it + $contentData = [ + 'html' => '

Test Content

This is a test email.

', + 'text' => 'Test Content\n\nThis is a test email.', + ]; + $response = $this->laposta->campaignApi()->updateContent($this->createdCampaignId, $contentData); + + $this->assertArrayHasKey('campaign', $response); + $campaignData = $response['campaign']; + $this->assertArrayHasKey('plaintext', $campaignData); + $this->assertArrayHasKey('html', $campaignData); + $this->assertStringContainsString('

Test Content

', $campaignData['html']); + + // Get the content and verify it + $response = $this->laposta->campaignApi()->getContent($this->createdCampaignId); + + $this->assertArrayHasKey('campaign', $response); + $campaignData = $response['campaign']; + $this->assertArrayHasKey('plaintext', $campaignData); + $this->assertArrayHasKey('html', $campaignData); + $this->assertStringContainsString('

Test Content

', $campaignData['html']); + } + + public function testSendTestMail(): void + { + $this->createdCampaignId = $this->createCampaignWithContent(); + + // Send test mail + $response = $this->laposta->campaignApi()->sendTestMail($this->createdCampaignId, $this->approvedSenderAddress); + + $this->assertArrayHasKey('campaign', $response); + $this->assertEquals($this->createdCampaignId, $response['campaign']['campaign_id']); + } + + public function testScheduleCampaign(): void + { + $this->createdCampaignId = $this->createCampaignWithContent(); + + // Schedule campaign for future delivery + $tomorrow = date('Y-m-d H:i:s', strtotime('+1 day')); + $response = $this->laposta->campaignApi()->schedule($this->createdCampaignId, $tomorrow); + + $this->assertArrayHasKey('campaign', $response); + $campaignData = $response['campaign']; + $this->assertEquals($this->createdCampaignId, $campaignData['campaign_id']); + + // Check if delivery_requested is set + $this->assertArrayHasKey('delivery_requested', $campaignData); + $this->assertSame($tomorrow, $campaignData['delivery_requested']); + } + + public function testSendCampaign(): void + { + $this->createdCampaignId = $this->createCampaignWithContent(); + + // Send the campaign directly + $response = $this->laposta->campaignApi()->send($this->createdCampaignId); + + $this->assertArrayHasKey('campaign', $response); + $campaignData = $response['campaign']; + $this->assertEquals($this->createdCampaignId, $campaignData['campaign_id']); + + // Status should be 'sending' or 'sent' + $this->assertNotEmpty($campaignData['delivery_requested']); } } diff --git a/tests/Integration/Api/FieldApiIntegrationTest.php b/tests/Integration/Api/FieldApiIntegrationTest.php index fd583f4..c3b45b0 100644 --- a/tests/Integration/Api/FieldApiIntegrationTest.php +++ b/tests/Integration/Api/FieldApiIntegrationTest.php @@ -149,7 +149,7 @@ public function testCreateFieldWithSelectOptions(): void $fieldData = $this->createFieldData('select_field', [ 'datatype' => 'select_single', 'datatype_display' => 'select', - 'options' => ['Optie 1', 'Optie 2', 'Optie 3'], + 'options' => ['Option 1', 'Option 2', 'Option 3'], ]); $response = $this->laposta->fieldApi()->create(self::$listIdForTests, $fieldData); diff --git a/tests/Integration/Api/ListApiIntegrationTest.php b/tests/Integration/Api/ListApiIntegrationTest.php index 4f6d2b0..f776c75 100644 --- a/tests/Integration/Api/ListApiIntegrationTest.php +++ b/tests/Integration/Api/ListApiIntegrationTest.php @@ -6,6 +6,7 @@ use LapostaApi\Exception\ApiException; use LapostaApi\Tests\Integration\BaseIntegrationTestCase; +use LapostaApi\Type\BulkMode; class ListApiIntegrationTest extends BaseIntegrationTestCase { @@ -145,4 +146,100 @@ public function testGetAllLists(): void } $this->assertTrue($found, 'Created list not found in all lists response.'); } + + public function testPurgeMembers(): void + { + // First, create a list for testing member purging + $listName = 'Test List Purge Members - ' . $this->generateRandomString(); + $createData = [ + 'name' => $listName, + 'from_email' => 'testpurge@example.com', + 'from_name' => 'Test Purge Members', + 'remarks' => 'Integration test list for purge members operation', + ]; + $createdList = $this->laposta->listApi()->create($createData); + $this->createdListId = $createdList['list']['list_id']; + + // Test purge members functionality + $response = $this->laposta->listApi()->purgeMembers($this->createdListId); + + // Verify the response structure + $this->assertArrayHasKey('list', $response); + $this->assertArrayHasKey('list_id', $response['list']); + $this->assertEquals($this->createdListId, $response['list']['list_id']); + + // Verify that the list contains the members count structure + $this->assertArrayHasKey('members', $response['list']); + $this->assertArrayHasKey('active', $response['list']['members']); + $this->assertArrayHasKey('unsubscribed', $response['list']['members']); + $this->assertArrayHasKey('cleaned', $response['list']['members']); + + // Verify that all member counts are 0 after purge + $this->assertEquals(0, $response['list']['members']['active']); + $this->assertEquals(0, $response['list']['members']['unsubscribed']); + $this->assertEquals(0, $response['list']['members']['cleaned']); + } + + public function testAddOrUpdateMembers(): void + { + // First, create a list for testing member operations + $listName = 'Test List Add/Update Members - ' . $this->generateRandomString(); + $createData = [ + 'name' => $listName, + 'from_email' => 'testaddupdate@example.com', + 'from_name' => 'Test Add/Update Members', + 'remarks' => 'Integration test list for add/update members operation', + ]; + $createdList = $this->laposta->listApi()->create($createData); + $this->createdListId = $createdList['list']['list_id']; + + // Prepare member data - include required fields based on your list configuration + $data = [ + 'mode' => BulkMode::ADD_AND_EDIT, + 'members' => + [ + [ + 'email' => 'test1@example.com', + ], + [ + 'email' => 'test2@example.com', + ], + [ + 'email' => 'error', + ], + ], + ]; + + // Test add members functionality + $response = $this->laposta->listApi()->addOrUpdateMembers($this->createdListId, $data); + + // Verify the report structure exists + $this->assertArrayHasKey('report', $response); + + // Verify that the report contains count fields + $this->assertArrayHasKey('provided_count', $response['report']); + $this->assertArrayHasKey('errors_count', $response['report']); + $this->assertArrayHasKey('skipped_count', $response['report']); + $this->assertArrayHasKey('edited_count', $response['report']); + $this->assertArrayHasKey('added_count', $response['report']); + + // Verify that we have the arrays for different categories of members + $this->assertArrayHasKey('errors', $response); + $this->assertArrayHasKey('skipped', $response); + $this->assertArrayHasKey('edited', $response); + $this->assertArrayHasKey('added', $response); + + // Verify the counts + $this->assertEquals(3, $response['report']['provided_count']); + $this->assertEquals(1, $response['report']['errors_count']); + $this->assertEquals(0, $response['report']['skipped_count']); + $this->assertEquals(0, $response['report']['edited_count']); + $this->assertEquals(2, $response['report']['added_count']); + + // Verify that the number of items in each array matches the reported counts + $this->assertCount($response['report']['errors_count'], $response['errors']); + $this->assertCount($response['report']['skipped_count'], $response['skipped']); + $this->assertCount($response['report']['edited_count'], $response['edited']); + $this->assertCount($response['report']['added_count'], $response['added']); + } } diff --git a/tests/Integration/Api/MemberApiIntegrationTest.php b/tests/Integration/Api/MemberApiIntegrationTest.php index 5a77220..123c4e3 100644 --- a/tests/Integration/Api/MemberApiIntegrationTest.php +++ b/tests/Integration/Api/MemberApiIntegrationTest.php @@ -30,7 +30,7 @@ protected function tearDown(): void try { $this->laposta->memberApi()->delete(self::$listIdForTests, $this->createdMemberId); } catch (ApiException $e) { - // fwrite(STDERR, 'Cleanup error (member): ' . $e->getMessage() . "\n"); + fwrite(STDERR, 'Cleanup error (member): ' . $e->getMessage() . "\n"); } } parent::tearDown(); diff --git a/tests/Integration/Api/SegmentApiIntegrationTest.php b/tests/Integration/Api/SegmentApiIntegrationTest.php index 9a3ea1e..e326c8b 100644 --- a/tests/Integration/Api/SegmentApiIntegrationTest.php +++ b/tests/Integration/Api/SegmentApiIntegrationTest.php @@ -30,7 +30,7 @@ protected function tearDown(): void try { $this->laposta->segmentApi()->delete(self::$listIdForTests, $this->createdSegmentId); } catch (ApiException $e) { - // fwrite(STDERR, 'Cleanup error (segment): ' . $e->getMessage() . "\n"); + fwrite(STDERR, 'Cleanup error (segment): ' . $e->getMessage() . "\n"); } } parent::tearDown(); @@ -65,12 +65,7 @@ public function testCreateSegment(): void 'definition' => $this->createDefinition(), ]; - try { - $response = $this->laposta->segmentApi()->create(self::$listIdForTests, $data); - } catch (ApiException $e) { - $a1 = $e->getResponseBody(); - $a = 1; - } + $response = $this->laposta->segmentApi()->create(self::$listIdForTests, $data); $this->assertArrayHasKey('segment', $response); $this->assertArrayHasKey('segment_id', $response['segment']); diff --git a/tests/Unit/Api/BaseApiTest.php b/tests/Unit/Api/BaseApiTest.php index 2066ba0..b58ad27 100644 --- a/tests/Unit/Api/BaseApiTest.php +++ b/tests/Unit/Api/BaseApiTest.php @@ -18,6 +18,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriFactoryInterface; use Psr\Http\Message\UriInterface; @@ -34,6 +35,9 @@ final class BaseApiTest extends TestCase /** @var ClientInterface&MockObject */ private ClientInterface $httpClient; + /** @var StreamFactoryInterface */ + private StreamFactoryInterface $streamFactory; + /** @var StreamInterface */ private StreamInterface $stream; @@ -47,7 +51,8 @@ protected function setUp(): void { // Mock Laposta and dependencies $this->httpClient = $this->createMock(ClientInterface::class); - $this->stream = (new StreamFactory())->createStream(); + $this->streamFactory = new StreamFactory(); + $this->stream = $this->streamFactory->createStream(); $this->requestFactory = $this->createMock(RequestFactoryInterface::class); $this->uriFactory = $this->createMock(UriFactoryInterface::class); @@ -61,11 +66,12 @@ protected function setUp(): void $this->laposta->method('getHttpClient')->willReturn($this->httpClient); $this->laposta->method('getRequestFactory')->willReturn($this->requestFactory); $this->laposta->method('getUriFactory')->willReturn($this->uriFactory); + $this->laposta->method('getStreamFactory')->willReturn($this->streamFactory); // Create concrete implementation of BaseApi for testing purposes $this->baseApi = $this->getMockBuilder(BaseApi::class) - ->setConstructorArgs([$this->laposta]) - ->getMock(); + ->setConstructorArgs([$this->laposta]) + ->getMock(); } public function testGetResource(): void @@ -307,6 +313,34 @@ public function testHandleResponseFailsWithHttpError(): void $method->invoke($this->baseApi, $request, $response); } + public function testHandleResponseWithInvalidJsonForHttpError(): void + { + // Mock request + $request = $this->createMock(RequestInterface::class); + + // Create stream with invalid JSON + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn('This is not valid JSON'); + + // Mock response with error code (4xx) and invalid JSON body + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(400); + $response->method('getBody')->willReturn($stream); + + // Get access to protected handleResponse method + $method = new ReflectionMethod(BaseApi::class, 'handleResponse'); + $method->setAccessible(true); + + // Test that ApiException is thrown with correct error message + try { + $method->invoke($this->baseApi, $request, $response); + $this->fail('An ApiException should be thrown'); + } catch (ApiException $e) { + // Verify that error message contains raw body + $this->assertStringContainsString('This is not valid JSON', $e->getMessage()); + } + } + public function testHandleResponseFailsWithInvalidJson(): void { // Mock response with successful status but invalid JSON @@ -337,45 +371,45 @@ public function testSendRequestCallsMethodsInCorrectOrder(): void // Create mock object of BaseApi $baseApiMock = $this->getMockBuilder(BaseApi::class) - ->disableOriginalConstructor() - ->onlyMethods(['buildUri', 'createRequest', 'dispatchRequest', 'handleResponse']) - ->getMock(); + ->disableOriginalConstructor() + ->onlyMethods(['buildUri', 'createRequest', 'dispatchRequest', 'handleResponse']) + ->getMock(); // Expectation for buildUri $baseApiMock->expects($this->once()) - ->method('buildUri') - ->with(['segment'], ['param' => 'value']) - ->willReturnCallback(function () use (&$calledMethods) { - $calledMethods[] = 'buildUri'; - return $this->createMock(UriInterface::class); - }); + ->method('buildUri') + ->with(['segment'], ['param' => 'value']) + ->willReturnCallback(function () use (&$calledMethods) { + $calledMethods[] = 'buildUri'; + return $this->createMock(UriInterface::class); + }); // Expectation for createRequest $baseApiMock->expects($this->once()) - ->method('createRequest') - ->with('GET', $this->isType('string'), null, $this->isInstanceOf(ContentType::class)) - ->willReturnCallback(function () use (&$calledMethods) { - $calledMethods[] = 'createRequest'; - return $this->createMock(RequestInterface::class); - }); + ->method('createRequest') + ->with('GET', $this->isType('string'), null, $this->isInstanceOf(ContentType::class)) + ->willReturnCallback(function () use (&$calledMethods) { + $calledMethods[] = 'createRequest'; + return $this->createMock(RequestInterface::class); + }); // Expectation for dispatchRequest $baseApiMock->expects($this->once()) - ->method('dispatchRequest') - ->with($this->isInstanceOf(RequestInterface::class)) - ->willReturnCallback(function () use (&$calledMethods) { - $calledMethods[] = 'dispatchRequest'; - return $this->createMock(ResponseInterface::class); - }); + ->method('dispatchRequest') + ->with($this->isInstanceOf(RequestInterface::class)) + ->willReturnCallback(function () use (&$calledMethods) { + $calledMethods[] = 'dispatchRequest'; + return $this->createMock(ResponseInterface::class); + }); // Expectation for handleResponse $baseApiMock->expects($this->once()) - ->method('handleResponse') - ->with($this->isInstanceOf(RequestInterface::class), $this->isInstanceOf(ResponseInterface::class)) - ->willReturnCallback(function () use (&$calledMethods) { - $calledMethods[] = 'handleResponse'; - return ['success' => true]; - }); + ->method('handleResponse') + ->with($this->isInstanceOf(RequestInterface::class), $this->isInstanceOf(ResponseInterface::class)) + ->willReturnCallback(function () use (&$calledMethods) { + $calledMethods[] = 'handleResponse'; + return ['success' => true]; + }); // Get access to sendRequest method $method = new ReflectionMethod(BaseApi::class, 'sendRequest'); diff --git a/tests/Unit/Api/ListApiTest.php b/tests/Unit/Api/ListApiTest.php index 03835da..b2abbd0 100644 --- a/tests/Unit/Api/ListApiTest.php +++ b/tests/Unit/Api/ListApiTest.php @@ -154,7 +154,7 @@ public function testPurgeList(): void ); } - public function testBulkOperation(): void + public function testAddOrUpdateMembers(): void { $listId = 'list123'; $bulkData = [ diff --git a/tests/Unit/Exception/ApiExceptionTest.php b/tests/Unit/Exception/ApiExceptionTest.php index 990c8ec..aaac3b1 100644 --- a/tests/Unit/Exception/ApiExceptionTest.php +++ b/tests/Unit/Exception/ApiExceptionTest.php @@ -33,6 +33,24 @@ public function testConstructor(): void $this->assertSame($response, $exception->getResponse()); } + /** + * Test if getRequest returns the correct request object + */ + public function testGetRequest(): void + { + // Mock RequestInterface + $request = $this->createMock(RequestInterface::class); + + // Mock ResponseInterface + $response = $this->createMock(ResponseInterface::class); + + // Create ApiException + $exception = new ApiException('Test exception', $request, $response); + + // Verify that the correct request object is returned + $this->assertSame($request, $exception->getRequest()); + } + /** * Test if getHttpStatus returns the correct status code */ @@ -131,7 +149,7 @@ public function testGetJsonResponseWithValidJson(): void // Call getJsonResponse and check result $expectedJson = ['error' => 'Not found', 'code' => 404]; - $this->assertEquals($expectedJson, $exception->getResponseJson()); + $this->assertEquals($expectedJson, $exception->getResponseData()); } /** @@ -154,7 +172,7 @@ public function testGetJsonResponseWithInvalidJson(): void $exception = new ApiException('Invalid response', $request, $response); // Call getJsonResponse and check result (should be empty array) - $this->assertEquals([], $exception->getResponseJson()); + $this->assertEquals([], $exception->getResponseData()); } /** @@ -177,7 +195,137 @@ public function testGetJsonResponseWithEmptyBody(): void $exception = new ApiException('Empty response', $request, $response); // Call getJsonResponse and check result (should be empty array) - $this->assertEquals([], $exception->getResponseJson()); + $this->assertEquals([], $exception->getResponseData()); + } + + /** + * Test error detail getters with complete error data + */ + public function testErrorDetailGettersWithCompleteData(): void + { + // Mock RequestInterface + $request = $this->createMock(RequestInterface::class); + + // Mock StreamInterface with complete error data + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(json_encode([ + 'error' => [ + 'type' => 'invalid_input', + 'code' => 400, + 'parameter' => 'email', + 'message' => 'Email address is invalid' + ] + ])); + + // Mock ResponseInterface + $response = $this->createMock(ResponseInterface::class); + $response->method('getBody')->willReturn($stream); + + // Create ApiException + $exception = new ApiException('Validation error', $request, $response); + + // Test each error detail getter + $this->assertEquals('invalid_input', $exception->getErrorType()); + $this->assertEquals(400, $exception->getErrorCode()); + $this->assertEquals('email', $exception->getErrorParameter()); + $this->assertEquals('Email address is invalid', $exception->getErrorMessage()); + } + + /** + * Test error detail getters with partial error data + */ + public function testErrorDetailGettersWithPartialData(): void + { + // Mock RequestInterface + $request = $this->createMock(RequestInterface::class); + + // Mock StreamInterface with partial error data (missing parameter and code) + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(json_encode([ + 'error' => [ + 'type' => 'server_error', + 'message' => 'Internal server error occurred' + ] + ])); + + // Mock ResponseInterface + $response = $this->createMock(ResponseInterface::class); + $response->method('getBody')->willReturn($stream); + + // Create ApiException + $exception = new ApiException('Server error', $request, $response); + + // Test each error detail getter + $this->assertEquals('server_error', $exception->getErrorType()); + $this->assertNull($exception->getErrorCode()); // Should be null since it's missing + $this->assertNull($exception->getErrorParameter()); // Should be null since it's missing + $this->assertEquals('Internal server error occurred', $exception->getErrorMessage()); + } + + /** + * Test error detail getters with no error data + */ + public function testErrorDetailGettersWithNoErrorData(): void + { + // Mock RequestInterface + $request = $this->createMock(RequestInterface::class); + + // Mock StreamInterface with response that has no error field + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(json_encode([ + 'status' => 'error', + 'message' => 'Something went wrong' + ])); + + // Mock ResponseInterface + $response = $this->createMock(ResponseInterface::class); + $response->method('getBody')->willReturn($stream); + + // Create ApiException + $exception = new ApiException('General error', $request, $response); + + // Test each error detail getter should return null + $this->assertNull($exception->getErrorType()); + $this->assertNull($exception->getErrorCode()); + $this->assertNull($exception->getErrorParameter()); + $this->assertNull($exception->getErrorMessage()); + } + + /** + * Test if error details are included in __toString output + */ + public function testToStringWithErrorDetails(): void + { + // Mock RequestInterface + $request = $this->createMock(RequestInterface::class); + + // Mock StreamInterface with error details + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(json_encode([ + 'error' => [ + 'type' => 'rate_limit_exceeded', + 'code' => 429, + 'parameter' => 'request', + 'message' => 'API rate limit exceeded' + ] + ])); + + // Mock ResponseInterface + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(429); + $response->method('getBody')->willReturn($stream); + + // Create ApiException + $exception = new ApiException('Rate limit exceeded', $request, $response); + + // Convert to string and check content + $string = (string)$exception; + + // String should contain all error details + $this->assertStringContainsString('Error Code: 429', $string); + $this->assertStringContainsString('Error Type: rate_limit_exceeded', $string); + $this->assertStringContainsString('Error Parameter: request', $string); + $this->assertStringContainsString('Error Message: API rate limit exceeded', $string); } /** diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index d2e6af8..2f43594 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -29,10 +29,10 @@ public function testCustomStatusCodeAndReasonPhrase(): void public function testStandardReasonPhraseForKnownStatusCodes(): void { - $response = new Response(403); + $response = new Response(401); - $this->assertEquals(403, $response->getStatusCode()); - $this->assertEquals('Forbidden', $response->getReasonPhrase()); + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals('Unauthorized', $response->getReasonPhrase()); } public function testUnknownStatusCodeReasonPhrase(): void @@ -57,15 +57,23 @@ public function testWithStatusReturnsNewInstance(): void $this->assertEquals('OK', $response->getReasonPhrase()); } - /** - * @covers \LapostaApi\Http\Response::getDefaultReasonPhrase - */ public function testWithStatusDefaultsToStandardReasonPhrase(): void { - $response = new Response(200); - $newResponse = $response->withStatus(500); + $response = new Response(); + + // Test a range of known HTTP status codes + $knownCodes = [200, 201, 400, 401, 402, 404, 429, 500]; + + foreach ($knownCodes as $code) { + $newResponse = $response->withStatus($code); + $this->assertEquals($code, $newResponse->getStatusCode()); + $this->assertNotEmpty($newResponse->getReasonPhrase()); + $this->assertIsString($newResponse->getReasonPhrase()); + } - $this->assertEquals(500, $newResponse->getStatusCode()); - $this->assertEquals('Internal Server Error', $newResponse->getReasonPhrase()); + // Test unknown code + $unknownResponse = $response->withStatus(999); + $this->assertEquals(999, $unknownResponse->getStatusCode()); + $this->assertEquals('', $unknownResponse->getReasonPhrase()); } }