diff --git a/README.md b/README.md index 036568c..21d8c38 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,25 @@ $ composer require codeinwp/optimole-sdk To begin, you need to create an account on [Optimole][1] and get your API key. You can then initialize the SDK with your API key using the `Optimole` facade: +### Initializing the SDK + ```php use Optimole\Sdk\Optimole; -Optimole::init('your-api-key'); +Optimole::init('your-api-key', $options); ``` +The second argument of the `init` method is optional. It allows you to pass options to the SDK that can be used to +configure it. These options are: + + * `base_domain`: The base domain to connect to Optimole's API. Default is `i.optimole.com`. + * `cache_buster`: A string value that will be appended to the URL of the optimized assets to bust Optimole's cache. + * `dashboard_api_url`: The URL of the dashboard API. Default is `https://dashboard.optimole.com/api`. + * `upload_api_credentials`: An array with the credentials to use for the upload API. The array should contain the keys `userKey` and `secret`. The default is empty and the SDK will use the API key provided in the `init` method to fetch them from the dashboard API. + * `upload_api_url`: The URL of the upload API. Default is `https://generateurls-prod.i.optimole.com/upload`. + +### Optimizing Images and Assets + The `Optimole` facade is your starting point for creating optimized images or other assets. You can control the optimization properties using the fluent interface provided by the SDK. Here's an example of how to optimize an image by changing its quality and cropping it: @@ -44,6 +57,26 @@ echo $image->getUrl(); echo (string) $image; ``` +### Offloading Images to Optimole + +The SDK also provides a way to offload images to Optimole. This is useful when you want to serve images from Optimole's +content delivery network. Here's an example of how to offload an image: + +```php +use Optimole\Sdk\Optimole; + +$imageId = Optimole::offload()->uploadImage('path/to/image.jpg', 'https://url/to/image.jpg'); +``` + +This will upload the image to Optimole and return the image ID. You can then use this image ID to interact with the +image. For example, you can get the URL of the offloaded image: + +```php +use Optimole\Sdk\Optimole; + +$imageUrl = Optimole::offload()->getImageUrl($imageId); +``` + ## Contributing Install dependencies using composer and run the test suite: diff --git a/composer.json b/composer.json index 35b9170..8496e57 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,20 @@ }, "require": { "php": ">=7.4", + "ext-json": "*", "symfony/polyfill-php80": "^1.29" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", + "guzzlehttp/guzzle": "^7.0", + "php-mock/php-mock-phpunit": "^2.10", + "php-stubs/wordpress-stubs": "^6.5", "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "szepeviktor/phpstan-wordpress": "^1.3" + }, + "suggest": { + "guzzlehttp/guzzle": "Use the Guzzle HTTP client to make requests to the API" }, "config": { "optimize-autoloader": true, diff --git a/phpstan.neon b/phpstan.neon index 13569a5..f82c8fa 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,5 @@ +includes: + - ./vendor/szepeviktor/phpstan-wordpress/extension.neon parameters: level: 5 paths: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0fac7f2..b6c1eac 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,5 @@ - + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class BadResponseException extends RuntimeException +{ +} diff --git a/src/Exception/DashboardApiException.php b/src/Exception/DashboardApiException.php new file mode 100644 index 0000000..b8c94a3 --- /dev/null +++ b/src/Exception/DashboardApiException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class DashboardApiException extends BadResponseException +{ +} diff --git a/src/Exception/InvalidDashboardApiResponseException.php b/src/Exception/InvalidDashboardApiResponseException.php new file mode 100644 index 0000000..44394e8 --- /dev/null +++ b/src/Exception/InvalidDashboardApiResponseException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class InvalidDashboardApiResponseException extends DashboardApiException +{ +} diff --git a/src/Exception/InvalidUploadApiResponseException.php b/src/Exception/InvalidUploadApiResponseException.php new file mode 100644 index 0000000..a2adad0 --- /dev/null +++ b/src/Exception/InvalidUploadApiResponseException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class InvalidUploadApiResponseException extends UploadApiException +{ +} diff --git a/src/Exception/UploadApiException.php b/src/Exception/UploadApiException.php new file mode 100644 index 0000000..4ca1784 --- /dev/null +++ b/src/Exception/UploadApiException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class UploadApiException extends BadResponseException +{ +} diff --git a/src/Exception/UploadFailedException.php b/src/Exception/UploadFailedException.php new file mode 100644 index 0000000..e5b9c83 --- /dev/null +++ b/src/Exception/UploadFailedException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class UploadFailedException extends RuntimeException +{ +} diff --git a/src/Exception/UploadLimitException.php b/src/Exception/UploadLimitException.php new file mode 100644 index 0000000..cb55a92 --- /dev/null +++ b/src/Exception/UploadLimitException.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +use Optimole\Sdk\ValueObject\OffloadUsage; + +class UploadLimitException extends UploadApiException +{ + /** + * The offload service usage. + */ + private OffloadUsage $usage; + + /** + * Constructor. + */ + public function __construct(OffloadUsage $usage, string $message = '', int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + + $this->usage = $usage; + } + + /** + * Get the offload service usage. + */ + public function getUsage(): OffloadUsage + { + return $this->usage; + } +} diff --git a/src/Http/ClientInterface.php b/src/Http/ClientInterface.php new file mode 100644 index 0000000..af309c2 --- /dev/null +++ b/src/Http/ClientInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Http; + +interface ClientInterface +{ + /** + * Sends an HTTP request and returns the JSON decoded body. + */ + public function sendRequest(string $method, string $url, $body = null, array $headers = []): ?array; +} diff --git a/src/Http/GuzzleClient.php b/src/Http/GuzzleClient.php new file mode 100644 index 0000000..10b2c4b --- /dev/null +++ b/src/Http/GuzzleClient.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Http; + +use GuzzleHttp\ClientInterface as GuzzleClientInterface; +use GuzzleHttp\Exception\BadResponseException as GuzzleBadResponseException; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Psr7\Request; +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Optimole; + +class GuzzleClient implements ClientInterface +{ + /** + * The Guzzle HTTP client. + */ + private GuzzleClientInterface $client; + + /** + * Constructor. + */ + public function __construct(GuzzleClientInterface $client) + { + $this->client = $client; + } + + /** + * {@inheritdoc} + */ + public function sendRequest(string $method, string $url, $body = null, array $headers = []): ?array + { + try { + $response = $this->client->send($this->createRequest($method, $url, $body, $headers), ['verify' => false]); + } catch (GuzzleBadResponseException $exception) { + throw new BadResponseException($exception->getMessage(), $exception->getCode(), $exception); + } catch (GuzzleException $exception) { + throw new RuntimeException($exception->getMessage(), $exception->getCode(), $exception); + } + + $body = (string) $response->getBody(); + + if (empty($body)) { + return null; + } + + $body = (array) json_decode($body, true); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new BadResponseException(sprintf('Unable to decode JSON response: %s', json_last_error_msg())); + } + + return $body; + } + + /** + * Create a request object. + */ + private function createRequest(string $method, string $url, $body = null, array $headers = []): Request + { + if (is_array($body)) { + $body = json_encode($body); + } + + if (null !== $body && !is_string($body)) { + throw new InvalidArgumentException('"body" must be a string or an array'); + } + + $headers = array_merge($headers, [ + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ]); + $method = strtolower($method); + + return new Request($method, $url, $headers, $body); + } +} diff --git a/src/Http/WordPressClient.php b/src/Http/WordPressClient.php new file mode 100644 index 0000000..23d3768 --- /dev/null +++ b/src/Http/WordPressClient.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Http; + +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Optimole; + +class WordPressClient implements ClientInterface +{ + /** + * The WordPress HTTP client. + */ + private \WP_Http $client; + + /** + * Constructor. + */ + public function __construct(\WP_Http $client) + { + $this->client = $client; + } + + /** + * {@inheritdoc} + */ + public function sendRequest(string $method, string $url, $body = null, array $headers = []): ?array + { + if (is_array($body)) { + $body = json_encode($body); + } + + if (null !== $body && !is_string($body)) { + throw new InvalidArgumentException('"body" must be a string or an array'); + } + + $args = [ + 'method' => $method, + 'headers' => array_merge($headers, [ + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ]), + ]; + + if (null !== $body) { + $args['body'] = $body; + } + + $response = $this->client->request($url, $args); + + if ($response instanceof \WP_Error) { + throw new RuntimeException((string) $response->get_error_message(), (int) $response->get_error_code()); + } elseif (200 !== $this->getResponseStatusCode($response)) { + throw new BadResponseException(sprintf('Response status code: %s', $this->getResponseStatusCode($response))); + } + + if (empty($response['body'])) { + return null; + } + + $body = (array) json_decode($response['body'], true); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new BadResponseException(sprintf('Unable to decode JSON response: %s', json_last_error_msg())); + } + + return $body; + } + + /** + * Get the status code from the given response. + */ + private function getResponseStatusCode(array $response): ?int + { + return $response['response']['code'] ?? null; + } +} diff --git a/src/Offload/Manager.php b/src/Offload/Manager.php new file mode 100644 index 0000000..1a5225c --- /dev/null +++ b/src/Offload/Manager.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Offload; + +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\InvalidDashboardApiResponseException; +use Optimole\Sdk\Exception\InvalidUploadApiResponseException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Exception\UploadApiException; +use Optimole\Sdk\Exception\UploadFailedException; +use Optimole\Sdk\Exception\UploadLimitException; +use Optimole\Sdk\Http\ClientInterface; +use Optimole\Sdk\ValueObject\OffloadUsage; + +class Manager +{ + /** + * The HTTP client. + */ + private ClientInterface $httpClient; + + /** + * The Optimole API key. + */ + private string $key; + + /** + * The manager options. + */ + private array $options; + + /** + * Constructor. + */ + public function __construct(ClientInterface $httpClient, string $key, array $options = []) + { + if (empty($options['dashboard_api_url'])) { + throw new InvalidArgumentException('Missing "dashboard_api_url" option'); + } elseif (empty($options['upload_api_url'])) { + throw new InvalidArgumentException('Missing "upload_api_url" option'); + } + + $this->httpClient = $httpClient; + $this->key = $key; + $this->options = array_merge([ + 'upload_api_credentials' => [], + ], $options); + + $this->options['dashboard_api_url'] = rtrim($this->options['dashboard_api_url'], '/'); + $this->options['upload_api_url'] = rtrim($this->options['upload_api_url'], '/'); + } + + /** + * Delete the image with the given image ID. + */ + public function deleteImage(string $imageId): void + { + try { + $this->requestToUploadApi([ + 'id' => $imageId, + 'deleteUrl' => 'true', + ]); + } catch (BadResponseException $exception) { + } + } + + /** + * Get the image URL for the given image ID. + */ + public function getImageUrl(string $imageId): ?string + { + try { + $response = $this->requestToUploadApi([ + 'id' => $imageId, + 'getUrl' => 'true', + ]); + } catch (BadResponseException $exception) { + return null; + } + + if (empty($response['getUrl'])) { + throw new InvalidUploadApiResponseException('Unable to get image URL from upload API'); + } + + return (string) $response['getUrl']; + } + + /** + * Get the offload service usage. + */ + public function getUsage(): OffloadUsage + { + $response = $this->requestToDashboardApi(); + + if (!isset($response['data']['offload_limit'], $response['data']['offloaded_images'])) { + throw new InvalidDashboardApiResponseException('Dashboard API did not return details about the offload service usage'); + } + + return new OffloadUsage((int) $response['data']['offloaded_images'], (int) $response['data']['offload_limit']); + } + + /** + * Update the metadata of the image with the given ID. + */ + public function updateImageMetadata(string $imageId, int $fileSize = 0, $height = 'auto', $width = 'auto'): void + { + if ('auto' !== $height && !is_int($height)) { + throw new InvalidArgumentException('Image height must be "auto" or an integer.'); + } elseif ('auto' !== $width && !is_int($width)) { + throw new InvalidArgumentException('Image width must be "auto" or an integer.'); + } + + $this->requestToUploadApi([ + 'id' => $imageId, + 'originalFileSize' => $fileSize, + 'height' => is_int($height) ? max(0, $height) : $height, + 'width' => is_int($width) ? max(0, $width) : $width, + 'updateDynamo' => 'success', + ]); + } + + /** + * Upload an image to Optimole and return its image ID. + */ + public function uploadImage(string $filename, string $imageUrl): string + { + if (!file_exists($filename)) { + throw new InvalidArgumentException(sprintf('File "%s" does not exist', $filename)); + } elseif (!is_readable($filename)) { + throw new InvalidArgumentException(sprintf('File "%s" is not readable', $filename)); + } + + $fileMimeType = $this->getMimeType($filename); + + try { + $response = $this->requestToUploadApi([ + 'originalUrl' => $imageUrl, + ]); + } catch (BadResponseException $exception) { + throw new UploadApiException('Unable to get signed URL from upload API', 0, $exception); + } + + if (isset($response['error']) && 'limit_exceeded' === $response['error']) { + throw new UploadLimitException(new OffloadUsage((int) $response['count'], (int) $response['limit'])); + } elseif (isset($response['error'])) { + throw new UploadApiException(sprintf('Upload API returned an error: %s', $response['error'])); + } elseif (isset($response['count'], $response['limit']) && $response['count'] >= $response['limit']) { + throw new UploadLimitException(new OffloadUsage((int) $response['count'], (int) $response['limit'])); + } elseif (!isset($response['tableId'], $response['uploadUrl'])) { + throw new InvalidUploadApiResponseException('Upload API did not return the table ID and upload URL'); + } + + $imageId = (string) $response['tableId']; + $uploadUrl = (string) $response['uploadUrl']; + $uploadUrlMimeType = preg_match('/Content-Type=([^&]*)/', urldecode($uploadUrl), $matches) ? $matches[1] : null; + + if (!is_string($uploadUrlMimeType)) { + throw new RuntimeException('Unable to parse content type from upload URL'); + } elseif (strtolower($fileMimeType) !== strtolower($uploadUrlMimeType)) { + throw new RuntimeException(sprintf('File "%s" MIME type "%s" does not match upload URL MIME type "%s"', $filename, $fileMimeType, $uploadUrlMimeType)); + } + + $image = file_get_contents($filename); + + if (false === $image) { + throw new RuntimeException(sprintf('Unable to get file "%s" content', $filename)); + } + + try { + $this->httpClient->sendRequest('put', $uploadUrl, $image, [ + 'Content-Type' => $fileMimeType, + ]); + } catch (BadResponseException $exception) { + throw new UploadFailedException(sprintf('Unable to upload file "%s": %s', $filename, $exception->getMessage()), 0, $exception); + } + + $imagesize = getimagesize($filename); + + $this->updateImageMetadata($imageId, filesize($filename) ?: 0, $imagesize && !empty($imagesize[1]) ? $imagesize[1] : 'auto', $imagesize && !empty($imagesize[0]) ? $imagesize[0] : 'auto'); + + return $imageId; + } + + /** + * Get the MIME type of the given file. + */ + private function getMimeType(string $filename): string + { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + + if (false === $finfo) { + throw new RuntimeException('Unable to open fileinfo database'); + } + + $mimeType = finfo_file($finfo, $filename); + + finfo_close($finfo); + + if (false === $mimeType) { + throw new RuntimeException(sprintf('Unable to get MIME type for file "%s"', $filename)); + } + + return $mimeType; + } + + /** + * Get the upload API credentials from the dashboard API. + */ + private function getUploadApiCredentialsFromDashboardApi(): array + { + $response = $this->requestToDashboardApi(); + + if (!isset($response['data']['cdn_key'], $response['data']['cdn_secret'])) { + throw new InvalidDashboardApiResponseException('Dashboard API did not return upload API credentials'); + } + + return [ + 'userKey' => $response['data']['cdn_key'], + 'secret' => $response['data']['cdn_secret'], + ]; + } + + /** + * Make a request to the dashboard API. + */ + private function requestToDashboardApi(): array + { + return $this->httpClient->sendRequest('post', sprintf('%s/optml/v2/account/details', $this->options['dashboard_api_url']), null, [ + 'Authorization' => sprintf('Bearer %s', $this->key), + 'Content-Type' => 'application/json', + ]); + } + + /** + * Make a request to the upload API. + */ + private function requestToUploadApi(array $body): array + { + if (!isset($this->options['upload_api_credentials']['userKey'], $this->options['upload_api_credentials']['secret'])) { + $this->options['upload_api_credentials'] = $this->getUploadApiCredentialsFromDashboardApi(); + } + + return $this->httpClient->sendRequest('post', $this->options['upload_api_url'], array_merge($this->options['upload_api_credentials'], $body), [ + 'Content-Type' => 'application/json', + ]); + } +} diff --git a/src/Optimole.php b/src/Optimole.php index 893eeba..afcb7de 100644 --- a/src/Optimole.php +++ b/src/Optimole.php @@ -13,17 +13,38 @@ namespace Optimole\Sdk; +use GuzzleHttp\Client; use Optimole\Sdk\Exception\BadMethodCallException; use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Http\ClientInterface; +use Optimole\Sdk\Http\GuzzleClient; +use Optimole\Sdk\Http\WordPressClient; +use Optimole\Sdk\Offload\Manager; use Optimole\Sdk\Resource\Asset; use Optimole\Sdk\Resource\Image; /** - * @method static Asset asset(string $assetUrl, string $cacheBuster = '') - * @method static Image image(string $imageUrl, string $cacheBuster = '') + * @method static Asset asset(string $assetUrl, string $cacheBuster = '') + * @method static Image image(string $imageUrl, string $cacheBuster = '') + * @method static Manager offload() */ final class Optimole { + /** + * The Optimole SDK version. + */ + public const VERSION = '1.0.0'; + + /** + * The Optimole dashboard API URL. + */ + private const DASHBOARD_API_URL = 'https://dashboard.optimole.com/api'; + + /** + * The Optimole upload API URL. + */ + private const UPLOAD_API_URL = 'https://generateurls-prod.i.optimole.com/upload'; + /** * The Optimole SDK factory. */ @@ -73,6 +94,8 @@ public static function init(string $key, array $options = []): void $options = array_merge([ 'base_domain' => 'i.optimole.com', 'cache_buster' => '', + 'dashboard_api_url' => self::DASHBOARD_API_URL, + 'upload_api_url' => self::UPLOAD_API_URL, ], $options); self::$instance = new self($key, $options); @@ -94,6 +117,14 @@ private function createImage(string $imageUrl, string $cacheBuster = ''): Image return new Image($this->getDomain(), $imageUrl, $cacheBuster ?: $this->options['cache_buster']); } + /** + * Create an instance of offload manager. + */ + private function createOffload(array $options = []): Manager + { + return new Manager($this->getHttpClient(), $this->key, array_merge($this->options, $options)); + } + /** * Get the Optimole domain to use. */ @@ -101,4 +132,18 @@ private function getDomain(): string { return $this->options['domain'] ?? sprintf('%s.%s', $this->key, $this->options['base_domain']); } + + /** + * Get the HTTP client available in the environment. + */ + private function getHttpClient(): ClientInterface + { + if (class_exists(Client::class)) { + return new GuzzleClient(new Client()); + } elseif (function_exists('_wp_http_get_object')) { + return new WordPressClient(_wp_http_get_object()); + } + + throw new RuntimeException('Unable to find a suitable HTTP client for this environment'); + } } diff --git a/src/ValueObject/OffloadUsage.php b/src/ValueObject/OffloadUsage.php new file mode 100644 index 0000000..4f87a4d --- /dev/null +++ b/src/ValueObject/OffloadUsage.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\ValueObject; + +final class OffloadUsage +{ + /** + * The current number of offloaded images. + */ + private int $current; + + /** + * The maximum number of offloaded images allowed. + */ + private int $limit; + + /** + * Constructor. + */ + public function __construct(int $current, int $limit) + { + $this->current = $current; + $this->limit = $limit; + } + + /** + * Get the current number of offloaded images. + */ + public function getCurrent(): int + { + return $this->current; + } + + /** + * Get the maximum number of offloaded images allowed. + */ + public function getLimit(): int + { + return $this->limit; + } +} diff --git a/tests/Mock/FunctionMockTrait.php b/tests/Mock/FunctionMockTrait.php new file mode 100644 index 0000000..c6e0887 --- /dev/null +++ b/tests/Mock/FunctionMockTrait.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Tests\Mock; + +use phpmock\phpunit\PHPMock; + +/** + * Adds mocking methods for mocking PHP functions. + */ +trait FunctionMockTrait +{ + use PHPMock; + + /** + * Get the namespace of the given class. + */ + private function getNamespace(string $className): string + { + return (new \ReflectionClass($className))->getNamespaceName(); + } +} diff --git a/tests/Unit/Http/GuzzleClientTest.php b/tests/Unit/Http/GuzzleClientTest.php new file mode 100644 index 0000000..17dce3c --- /dev/null +++ b/tests/Unit/Http/GuzzleClientTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Tests\Unit\Http; + +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\BadResponseException as GuzzleBadResponseException; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Psr7\Request; +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Http\GuzzleClient; +use Optimole\Sdk\Optimole; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; + +class GuzzleClientTest extends TestCase +{ + public function testSendRequestConvertsGuzzleBadResponseException() + { + $this->expectException(BadResponseException::class); + + $guzzle = $this->createMock(ClientInterface::class); + + $guzzle->expects($this->once()) + ->method('send') + ->willThrowException($this->createMock(GuzzleBadResponseException::class)); + + (new GuzzleClient($guzzle))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestConvertsGuzzleException() + { + $this->expectException(RuntimeException::class); + + $guzzle = $this->createMock(ClientInterface::class); + + $guzzle->expects($this->once()) + ->method('send') + ->willThrowException($this->createMock(GuzzleException::class)); + + (new GuzzleClient($guzzle))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestIsSuccessful() + { + $guzzle = $this->createMock(ClientInterface::class); + + $guzzle->expects($this->once()) + ->method('send') + ->willReturnCallback(function ($request, $options) { + $this->assertInstanceOf(Request::class, $request); + + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('https://example.com', (string) $request->getUri()); + $this->assertSame('{"foo":"bar"}', (string) $request->getBody()); + $this->assertSame(['Host' => ['example.com'], 'Content-Type' => ['application/json'], 'User-Agent' => [sprintf('optimole-sdk-php/%s', Optimole::VERSION)]], $request->getHeaders()); + $this->assertSame(['verify' => false], $options); + + return $this->createMock(ResponseInterface::class); + }); + + (new GuzzleClient($guzzle))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestWithBodyNotStringOrArray() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"body" must be a string or an array'); + + $guzzle = $this->createMock(ClientInterface::class); + + (new GuzzleClient($guzzle))->sendRequest('GET', 'https://example.com', new \stdClass(), ['Content-Type' => 'application/json']); + } +} diff --git a/tests/Unit/Http/WordPressClientTest.php b/tests/Unit/Http/WordPressClientTest.php new file mode 100644 index 0000000..b779341 --- /dev/null +++ b/tests/Unit/Http/WordPressClientTest.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Http\WordPressClient; +use Optimole\Sdk\Optimole; +use PHPUnit\Framework\TestCase; + +class WordPressClientTest extends TestCase +{ + public function testSendRequestReturnsJsonDecodedBody() + { + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(['body' => '{"bar":"foo"}', 'response' => ['code' => 200]]); + + $this->assertSame(['bar' => 'foo'], (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json'])); + } + + public function testSendRequestReturnsNullWithEmptyBody() + { + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(['body' => '', 'response' => ['code' => 200]]); + + $this->assertNull((new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json'])); + } + + public function testSendRequestWhenCannotDecodeJsonBody() + { + $this->expectException(BadResponseException::class); + $this->expectExceptionMessage('Unable to decode JSON response: State mismatch (invalid or malformed JSON)'); + + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(['body' => '[}', 'response' => ['code' => 200]]); + + (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestWhenRequestDoesntReturn200StatusCode() + { + $this->expectException(BadResponseException::class); + $this->expectExceptionMessage('Response status code: 400'); + + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(['response' => ['code' => 400]]); + + (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestWhenRequestReturnsWpErrorObject() + { + $this->expectException(RuntimeException::class); + + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(new WP_Error('http_request_failed', 'An error occurred')); + + (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestWithBodyNotStringOrArray() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"body" must be a string or an array'); + + $wordpressHttp = $this->createMock(WP_Http::class); + + (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', new stdClass(), ['Content-Type' => 'application/json']); + } +} diff --git a/tests/Unit/Offload/ManagerTest.php b/tests/Unit/Offload/ManagerTest.php new file mode 100644 index 0000000..d92e5f7 --- /dev/null +++ b/tests/Unit/Offload/ManagerTest.php @@ -0,0 +1,336 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Tests\Unit\Offload; + +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\InvalidDashboardApiResponseException; +use Optimole\Sdk\Exception\InvalidUploadApiResponseException; +use Optimole\Sdk\Http\ClientInterface; +use Optimole\Sdk\Offload\Manager; +use Optimole\Sdk\Tests\Mock\FunctionMockTrait; +use PHPUnit\Framework\TestCase; + +class ManagerTest extends TestCase +{ + use FunctionMockTrait; + + public function testConstructorWithMissingDashboardApiOption() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing "dashboard_api_url" option'); + + new Manager($this->createMock(ClientInterface::class), 'optimole_key', ['upload_api_url' => 'https://upload_api_url']); + } + + public function testConstructorWithMissingUploadApiOption() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing "upload_api_url" option'); + + new Manager($this->createMock(ClientInterface::class), 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url']); + } + + public function testDeleteImage() + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(function (...$args) { + static $expected = [ + [ + ['post', 'https://dashboard_api_url/optml/v2/account/details', null, ['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json']], + ['data' => ['cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret']], + ], + [ + ['post', 'https://upload_api_url', ['userKey' => 'cdn_key', 'secret' => 'cdn_secret', 'id' => 'image_id', 'deleteUrl' => 'true'], ['Content-Type' => 'application/json']], + ['success'], + ], + ]; + + list($expectedArgument, $return) = array_shift($expected); + + $this->assertSame($expectedArgument, $args); + + return $return; + }); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->deleteImage('image_id'); + } + + public function testDeleteImageWithBadResponseException() + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(function (...$args) { + static $expected = [ + [ + ['post', 'https://dashboard_api_url/optml/v2/account/details', null, ['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json']], + ['data' => ['cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret']], + ], + [ + ['post', 'https://upload_api_url', ['userKey' => 'cdn_key', 'secret' => 'cdn_secret', 'id' => 'image_id', 'deleteUrl' => 'true'], ['Content-Type' => 'application/json']], + BadResponseException::class, + ], + ]; + + list($expectedArgument, $return) = array_shift($expected); + + $this->assertSame($expectedArgument, $args); + + if (is_a($return, \Throwable::class, true)) { + throw new $return(); + } + + return $return; + }); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->deleteImage('image_id'); + } + + public function testDeleteImageWithNonBadResponseException() + { + $this->expectException(\RuntimeException::class); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(function (...$args) { + static $expected = [ + [ + ['post', 'https://dashboard_api_url/optml/v2/account/details', null, ['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json']], + ['data' => ['cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret']], + ], + [ + ['post', 'https://upload_api_url', ['userKey' => 'cdn_key', 'secret' => 'cdn_secret', 'id' => 'image_id', 'deleteUrl' => 'true'], ['Content-Type' => 'application/json']], + \RuntimeException::class, + ], + ]; + + list($expectedArgument, $return) = array_shift($expected); + + $this->assertSame($expectedArgument, $args); + + if (is_a($return, \Throwable::class, true)) { + throw new $return(); + } + + return $return; + }); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->deleteImage('image_id'); + } + + public function testGetImageUrl() + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(function (...$args) { + static $expected = [ + [ + ['post', 'https://dashboard_api_url/optml/v2/account/details', null, ['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json']], + ['data' => ['cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret']], + ], + [ + ['post', 'https://upload_api_url', ['userKey' => 'cdn_key', 'secret' => 'cdn_secret', 'id' => 'image_id', 'getUrl' => 'true'], ['Content-Type' => 'application/json']], + ['getUrl' => 'https://cdn.optimole.com/image_id'], + ], + ]; + + list($expectedArgument, $return) = array_shift($expected); + + $this->assertSame($expectedArgument, $args); + + return $return; + }); + + $this->assertSame('https://cdn.optimole.com/image_id', (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->getImageUrl('image_id')); + } + + public function testGetImageUrlReturnNullWithBadResponseException() + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(function (...$args) { + static $expected = [ + [ + ['post', 'https://dashboard_api_url/optml/v2/account/details', null, ['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json']], + ['data' => ['cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret']], + ], + [ + ['post', 'https://upload_api_url', ['userKey' => 'cdn_key', 'secret' => 'cdn_secret', 'id' => 'image_id', 'getUrl' => 'true'], ['Content-Type' => 'application/json']], + BadResponseException::class, + ], + ]; + + list($expectedArgument, $return) = array_shift($expected); + + $this->assertSame($expectedArgument, $args); + + if (is_a($return, \Throwable::class, true)) { + throw new $return(); + } + + return $return; + }); + + $this->assertNull((new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->getImageUrl('image_id')); + } + + public function testGetImageUrlWithMissingGetUrlKey() + { + $this->expectException(InvalidUploadApiResponseException::class); + $this->expectExceptionMessage('Unable to get image URL from upload API'); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(function (...$args) { + static $expected = [ + [ + ['post', 'https://dashboard_api_url/optml/v2/account/details', null, ['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json']], + ['data' => ['cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret']], + ], + [ + ['post', 'https://upload_api_url', ['userKey' => 'cdn_key', 'secret' => 'cdn_secret', 'id' => 'image_id', 'getUrl' => 'true'], ['Content-Type' => 'application/json']], + [], + ], + ]; + + list($expectedArgument, $return) = array_shift($expected); + + $this->assertSame($expectedArgument, $args); + + return $return; + }); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->getImageUrl('image_id'); + } + + public function testGetUsage() + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->with($this->identicalTo('post'), $this->identicalTo('https://dashboard_api_url/optml/v2/account/details'), $this->identicalTo(null), $this->identicalTo(['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json'])) + ->willReturn(['data' => ['offload_limit' => 5000, 'offloaded_images' => 42]]); + + $usage = (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->getUsage(); + + $this->assertSame(42, $usage->getCurrent()); + $this->assertSame(5000, $usage->getLimit()); + } + + public function testGetUsageWithMissingOffloadedImages() + { + $this->expectException(InvalidDashboardApiResponseException::class); + $this->expectExceptionMessage('Dashboard API did not return details about the offload service usage'); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->with($this->identicalTo('post'), $this->identicalTo('https://dashboard_api_url/optml/v2/account/details'), $this->identicalTo(null), $this->identicalTo(['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json'])) + ->willReturn(['data' => ['offload_limit' => 5000]]); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->getUsage(); + } + + public function testGetUsageWithMissingOffloadLimit() + { + $this->expectException(InvalidDashboardApiResponseException::class); + $this->expectExceptionMessage('Dashboard API did not return details about the offload service usage'); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->with($this->identicalTo('post'), $this->identicalTo('https://dashboard_api_url/optml/v2/account/details'), $this->identicalTo(null), $this->identicalTo(['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json'])) + ->willReturn(['data' => ['offloaded_images' => 42]]); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->getUsage(); + } + + public function testUpdateImageMetadata() + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(function (...$args) { + static $expected = [ + [ + ['post', 'https://dashboard_api_url/optml/v2/account/details', null, ['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json']], + ['data' => ['cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret']], + ], + [ + ['post', 'https://upload_api_url', ['userKey' => 'cdn_key', 'secret' => 'cdn_secret', 'id' => 'image_id', 'originalFileSize' => 42, 'height' => 100, 'width' => 200, 'updateDynamo' => 'success'], ['Content-Type' => 'application/json']], + ['success'], + ], + ]; + + list($expectedArgument, $return) = array_shift($expected); + + $this->assertSame($expectedArgument, $args); + + return $return; + }); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->updateImageMetadata('image_id', 42, 100, 200); + } + + public function testUpdateImageMetadataWithInvalidImageHeight() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Image height must be "auto" or an integer.'); + + (new Manager($this->createMock(ClientInterface::class), 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->updateImageMetadata('image_id', 42, '', 200); + } + + public function testUpdateImageMetadataWithInvalidImageWidth() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Image width must be "auto" or an integer.'); + + (new Manager($this->createMock(ClientInterface::class), 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->updateImageMetadata('image_id', 42, 100, ''); + } + + public function testUploadImageWhenFileDoesntExist() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('File "non_existent_file" does not exist'); + + $httpClient = $this->createMock(ClientInterface::class); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->uploadImage('non_existent_file', 'image_url'); + } + + /** + * @runInSeparateProcess + */ + public function testUploadImageWhenFileIsntReadable() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('File "non_readable_file" is not readable'); + + $file_exists = $this->getFunctionMock($this->getNamespace(Manager::class), 'file_exists'); + $httpClient = $this->createMock(ClientInterface::class); + + $file_exists->expects($this->once()) + ->with($this->identicalTo('non_readable_file')) + ->willReturn(true); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->uploadImage('non_readable_file', 'image_url'); + } +} diff --git a/tests/Unit/OptimoleTest.php b/tests/Unit/OptimoleTest.php index 6d266cd..4293e51 100644 --- a/tests/Unit/OptimoleTest.php +++ b/tests/Unit/OptimoleTest.php @@ -15,6 +15,7 @@ use Optimole\Sdk\Exception\BadMethodCallException; use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Offload\Manager; use Optimole\Sdk\Optimole; use Optimole\Sdk\Resource\Asset; use Optimole\Sdk\Resource\Image; @@ -102,6 +103,47 @@ public function testImageUsesDomainOption(): void $this->assertSame('https://foo/https://example.com/image.jpg', (string) Optimole::image('https://example.com/image.jpg')); } + public function testOffloadReturnsOffloadManagerObject(): void + { + Optimole::init('key'); + + $this->assertInstanceOf(Manager::class, Optimole::offload()); + } + + public function testOffloadUsesDashboardApiOption(): void + { + Optimole::init('key', [ + 'dashboard_api_url' => 'dashboard_api_url', + ]); + + $manager = Optimole::offload(); + + $optionsProperty = (new \ReflectionObject($manager))->getProperty('options'); + $optionsProperty->setAccessible(true); + + $options = $optionsProperty->getValue($manager); + + $this->assertArrayHasKey('dashboard_api_url', $options); + $this->assertSame('dashboard_api_url', $options['dashboard_api_url']); + } + + public function testOffloadUsesUploadApiOption(): void + { + Optimole::init('key', [ + 'upload_api_url' => 'upload_api_url', + ]); + + $manager = Optimole::offload(); + + $optionsProperty = (new \ReflectionObject($manager))->getProperty('options'); + $optionsProperty->setAccessible(true); + + $options = $optionsProperty->getValue($manager); + + $this->assertArrayHasKey('upload_api_url', $options); + $this->assertSame('upload_api_url', $options['upload_api_url']); + } + public function testThrowsExceptionIfMethodDoesNotExist(): void { $this->expectException(BadMethodCallException::class); diff --git a/tests/Unit/ValueObject/OffloadUsageTest.php b/tests/Unit/ValueObject/OffloadUsageTest.php new file mode 100644 index 0000000..0dda966 --- /dev/null +++ b/tests/Unit/ValueObject/OffloadUsageTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Tests\Unit\ValueObject; + +use Optimole\Sdk\ValueObject\OffloadUsage; +use PHPUnit\Framework\TestCase; + +class OffloadUsageTest extends TestCase +{ + public function testGetCurrent() + { + $this->assertSame(10, (new OffloadUsage(10, 100))->getCurrent()); + } + + public function testGetLimit() + { + $this->assertSame(100, (new OffloadUsage(10, 100))->getLimit()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..4babce1 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +require_once __DIR__.'/../vendor/autoload.php'; +require_once __DIR__.'/../vendor/php-stubs/wordpress-stubs/wordpress-stubs.php';