From d2b66434b6d194970e9e619c42faa4ec1e119d13 Mon Sep 17 00:00:00 2001
From: Grzegorz Rola <grola@seostudio.pl>
Date: Fri, 1 Feb 2019 11:20:03 +0000
Subject: [PATCH] Feature/first release

---
 .gitattributes                                |  15 ++
 .gitignore                                    |   5 +
 .gitlab-ci.yml                                |   6 +
 README.md                                     |   6 +-
 changelog.txt                                 |   2 +
 composer.json                                 |   4 +-
 phpunit-integration.xml                       |  28 +++
 phpunit-unit.xml                              |  21 ++
 src/Authentication/JWTSaasToken.php           |  66 ++++++
 src/Authentication/JWTToken.php               |  81 +++++++
 src/Authentication/NullToken.php              |  54 +++++
 src/Authentication/Token.php                  |  32 +++
 src/Client/ApiClientOptions.php               |  32 +++
 src/Client/CachedClient.php                   | 111 +++++++++
 src/Client/Client.php                         |  41 ++++
 src/Client/ClientFactory.php                  |  34 +++
 src/Client/ClientImplementation.php           | 214 ++++++++++++++++++
 src/Client/RequestCacheInfoResolver.php       | 139 ++++++++++++
 src/Request/BasicRequest.php                  |  54 +++++
 src/Request/Request.php                       |  27 +++
 src/Response/ApiResponse.php                  |  35 +++
 src/Response/AuthApiResponse.php              |  14 ++
 src/Response/ProtectedResponse.php            |  27 +++
 src/Response/RawResponse.php                  | 113 +++++++++
 src/Response/Response.php                     |  43 ++++
 src/Response/Traits/ApiResponseDecorator.php  | 145 ++++++++++++
 .../Traits/AuthApiResponseDecorator.php       |  38 ++++
 .../Traits/PagedListImplementation.php        |  44 ++++
 .../Exception/CannotUnserializeException.php  |  13 ++
 src/Serializer/JsonSerializer.php             |  46 ++++
 src/Serializer/Serializer.php                 |  23 ++
 src/Serializer/SerializerFactory.php          |  16 ++
 src/Serializer/SerializerOptions.php          |  11 +
 tests/docker-compose.yaml                     | 172 ++++++++++++++
 tests/integration/bootstrap.php               |  28 +++
 .../unit/Authentication/TestJWTSaasToken.php  |  64 ++++++
 tests/unit/Authentication/TestJWToken.php     |  33 +++
 tests/unit/Client/TestClientFactory.php       |  67 ++++++
 tests/unit/bootstrap.php                      |   9 +
 39 files changed, 1910 insertions(+), 3 deletions(-)
 create mode 100644 .gitattributes
 create mode 100644 .gitignore
 create mode 100644 .gitlab-ci.yml
 create mode 100644 changelog.txt
 create mode 100644 phpunit-integration.xml
 create mode 100644 phpunit-unit.xml
 create mode 100644 src/Authentication/JWTSaasToken.php
 create mode 100644 src/Authentication/JWTToken.php
 create mode 100644 src/Authentication/NullToken.php
 create mode 100644 src/Authentication/Token.php
 create mode 100644 src/Client/ApiClientOptions.php
 create mode 100644 src/Client/CachedClient.php
 create mode 100644 src/Client/Client.php
 create mode 100644 src/Client/ClientFactory.php
 create mode 100644 src/Client/ClientImplementation.php
 create mode 100644 src/Client/RequestCacheInfoResolver.php
 create mode 100644 src/Request/BasicRequest.php
 create mode 100644 src/Request/Request.php
 create mode 100644 src/Response/ApiResponse.php
 create mode 100644 src/Response/AuthApiResponse.php
 create mode 100644 src/Response/ProtectedResponse.php
 create mode 100644 src/Response/RawResponse.php
 create mode 100644 src/Response/Response.php
 create mode 100644 src/Response/Traits/ApiResponseDecorator.php
 create mode 100644 src/Response/Traits/AuthApiResponseDecorator.php
 create mode 100644 src/Response/Traits/PagedListImplementation.php
 create mode 100644 src/Serializer/Exception/CannotUnserializeException.php
 create mode 100644 src/Serializer/JsonSerializer.php
 create mode 100644 src/Serializer/Serializer.php
 create mode 100644 src/Serializer/SerializerFactory.php
 create mode 100644 src/Serializer/SerializerOptions.php
 create mode 100644 tests/docker-compose.yaml
 create mode 100644 tests/integration/bootstrap.php
 create mode 100644 tests/unit/Authentication/TestJWTSaasToken.php
 create mode 100644 tests/unit/Authentication/TestJWToken.php
 create mode 100644 tests/unit/Client/TestClientFactory.php
 create mode 100644 tests/unit/bootstrap.php

diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..a612c0c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,15 @@
+tests/ export-ignore
+vendor/ export-ignore
+.editorconfig export-ignore
+.gitattributes export-ignore
+.gitignore export-ignore
+.git/ export-ignore
+.gitlab-ci.yml export-ignore
+.idea export-ignore
+apigen.neon export-ignore
+build-coverage/ export-ignore
+docs/ export-ignore
+LICENSE.md export-ignore
+phpcs.xml.dist export-ignore
+phpunit-integration.xml export-ignore
+phpunit-unit.xml export-ignore
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4b86223
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/vendor/
+.idea
+composer.lock
+build-coverage
+swagger
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..db521c1
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,6 @@
+variables:
+  DISABLE_ACCEPTANCE: "1"
+  DISABLE_FUNCTIONAL: "1"
+
+include: 'https://gitlab.com/wpdesk/gitlab-ci/raw/master/gitlab-ci-1.2.yml'
+
diff --git a/README.md b/README.md
index b6395ce..a8ccb86 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,6 @@
-# wp-api-client
+[![pipeline status](https://gitlab.com/wpdesk/wp-api-client/badges/master/pipeline.svg)](https://gitlab.com/wpdesk/wp-api-client/commits/master) 
+Integration: [![coverage report](https://gitlab.com/wpdesk/wp-api-client/badges/master/coverage.svg?job=integration+test+lastest+coverage)](https://gitlab.com/wpdesk/wp-api-client/commits/master)
+Unit: [![coverage report](https://gitlab.com/wpdesk/wp-api-client/badges/master/coverage.svg?job=unit+test+lastest+coverage)](https://gitlab.com/wpdesk/wp-api-client/commits/master)
 
+API Client
+==========
diff --git a/changelog.txt b/changelog.txt
new file mode 100644
index 0000000..03dc550
--- /dev/null
+++ b/changelog.txt
@@ -0,0 +1,2 @@
+= 1.0 - 2019-01-31 =
+* First release
diff --git a/composer.json b/composer.json
index fc3057c..653e02b 100644
--- a/composer.json
+++ b/composer.json
@@ -9,8 +9,8 @@
   "require": {
     "php": ">=5.5",
     "psr/log": "^1.0.1",
-    "wpdesk/wp-cache": "dev-master",
-    "wpdesk/wp-http-client": "dev-master",
+    "wpdesk/wp-cache": "dev-feature/first-release",
+    "wpdesk/wp-http-client": "dev-feature/first-release",
     "psr/simple-cache": "^1.0"
   },
   "require-dev": {
diff --git a/phpunit-integration.xml b/phpunit-integration.xml
new file mode 100644
index 0000000..4a342ab
--- /dev/null
+++ b/phpunit-integration.xml
@@ -0,0 +1,28 @@
+<phpunit bootstrap="tests/integration/bootstrap.php"
+         backupGlobals="false"
+     >
+    <testsuites>
+        <testsuite>
+            <directory prefix="Test" suffix=".php">./tests/integration</directory>
+        </testsuite>
+    </testsuites>
+
+    <filter>
+        <whitelist>
+            <directory suffix=".php">src</directory>
+        </whitelist>
+    </filter>
+
+    <logging>
+        <log type="junit" target="build-coverage/report.junit.xml"/>
+        <log type="coverage-html" target="build-coverage/coverage" charset="UTF-8" yui="true" highlight="true"/>
+        <log type="coverage-text" target="build-coverage/coverage.txt"/>
+        <log type="coverage-clover" target="build-coverage/clover.xml"/>
+    </logging>
+
+    <php>
+        <env name="WP_DEVELOP_DIR" value="/tmp/wordpress-develop"/>
+        <env name="WC_DEVELOP_DIR" value="/tmp/woocommerce"/>
+    </php>
+
+</phpunit>
\ No newline at end of file
diff --git a/phpunit-unit.xml b/phpunit-unit.xml
new file mode 100644
index 0000000..31e5c9f
--- /dev/null
+++ b/phpunit-unit.xml
@@ -0,0 +1,21 @@
+<phpunit bootstrap="tests/unit/bootstrap.php">
+    <testsuites>
+        <testsuite>
+            <directory prefix="Test" suffix=".php">./tests/unit/</directory>
+        </testsuite>
+    </testsuites>
+
+    <filter>
+        <whitelist>
+            <directory suffix=".php">src</directory>
+        </whitelist>
+    </filter>
+
+    <logging>
+        <log type="junit" target="build-coverage/report.junit.xml"/>
+        <log type="coverage-html" target="build-coverage/coverage" charset="UTF-8" yui="true" highlight="true"/>
+        <log type="coverage-text" target="build-coverage/coverage.txt"/>
+        <log type="coverage-clover" target="build-coverage/clover.xml"/>
+    </logging>
+
+</phpunit>
diff --git a/src/Authentication/JWTSaasToken.php b/src/Authentication/JWTSaasToken.php
new file mode 100644
index 0000000..3465b21
--- /dev/null
+++ b/src/Authentication/JWTSaasToken.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace WPDesk\ApiClient\Authentication;
+
+class JWTSaasToken implements Token
+{
+    const SHOP_ID_PARAM = 'shop';
+
+    const ROLE_PARAM = 'ROLE_SHOP';
+
+
+    /** @var JWTToken */
+    private $token;
+
+    /**
+     * JWTToken constructor.
+     * @param string $token
+     */
+    public function __construct(JWTToken $token)
+    {
+        $this->token = $token;
+    }
+
+    public function getAuthString()
+    {
+        return $this->token->getAuthString();
+    }
+
+    public function isExpired()
+    {
+        return $this->token->isExpired();
+    }
+
+    public function isSignatureValid()
+    {
+        return $this->token->isSignatureValid();
+    }
+
+    public function __toString()
+    {
+        return $this->token->__toString();
+    }
+
+    /**
+     * If there is shop id in the token
+     *
+     * @return bool
+     */
+    public function hasShopId()
+    {
+        $info = $this->token->getDecodedPublicTokenInfo();
+        return !empty($info[self::SHOP_ID_PARAM]) && in_array(self::ROLE_PARAM, $info['roles']);
+    }
+
+    /**
+     * Get shop id from token
+     *
+     * @return int
+     */
+    public function getShopId()
+    {
+        $info = $this->token->getDecodedPublicTokenInfo();
+        return (int)$info[self::SHOP_ID_PARAM];
+    }
+
+}
\ No newline at end of file
diff --git a/src/Authentication/JWTToken.php b/src/Authentication/JWTToken.php
new file mode 100644
index 0000000..c6eaccf
--- /dev/null
+++ b/src/Authentication/JWTToken.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace WPDesk\ApiClient\Authentication;
+
+class JWTToken implements Token
+{
+    const CONSIDER_EXPIRED_WHEN_LESS = 2;
+
+    const EXPIRED_IN_SECONDS_PARAM = 'exp';
+
+    /** @var  string */
+    private $token;
+
+    /**
+     * JWTToken constructor.
+     * @param string $token
+     */
+    public function __construct($token)
+    {
+        $this->token = $token;
+    }
+
+    /**
+     * @return string
+     */
+    public function __toString()
+    {
+        return $this->token;
+    }
+
+    /**
+     * Get string to perform authentication
+     *
+     * @return string
+     */
+    public function getAuthString()
+    {
+        return 'Bearer ' . $this->__toString();
+    }
+
+    /**
+     * Returns public data from token
+     *
+     * @return array
+     */
+    public function getDecodedPublicTokenInfo()
+    {
+        $tokenParts = explode('.', $this->__toString());
+        if (!empty($tokenParts[1])) {
+            $infoPart = base64_decode($tokenParts[1]);
+            return json_decode($infoPart, true);
+        }
+        return [];
+    }
+
+    /**
+     * Is token expired or very soon to be expired?
+     *
+     * @return bool
+     */
+    public function isExpired()
+    {
+        $tokenInfo = $this->getDecodedPublicTokenInfo();
+        if (!empty($tokenInfo[self::EXPIRED_IN_SECONDS_PARAM])) {
+            return $tokenInfo[self::EXPIRED_IN_SECONDS_PARAM] - time() < self::CONSIDER_EXPIRED_WHEN_LESS;
+        }
+        return true;
+    }
+
+    /**
+     * Validates token signature
+     *
+     * @return bool
+     */
+    public function isSignatureValid()
+    {
+        // @TODO
+        return true;
+    }
+
+}
\ No newline at end of file
diff --git a/src/Authentication/NullToken.php b/src/Authentication/NullToken.php
new file mode 100644
index 0000000..d46c595
--- /dev/null
+++ b/src/Authentication/NullToken.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace WPDesk\ApiClient\Authentication;
+
+/**
+ * Null object pattern
+ *
+ * @package WPDesk\SaasPlatformClient\Authentication
+ */
+class NullToken implements Token
+{
+    public function __construct()
+    {
+    }
+
+    /**
+     * @return string
+     */
+    public function __toString()
+    {
+        return '';
+    }
+
+    /**
+     * Get string to perform authentication
+     *
+     * @return string
+     */
+    public function getAuthString()
+    {
+        return '';
+    }
+
+    /**
+     * Is token expired or very soon to be expired?
+     *
+     * @return bool
+     */
+    public function isExpired()
+    {
+        return true;
+    }
+
+    /**
+     * Validates token signature
+     *
+     * @return bool
+     */
+    public function isSignatureValid()
+    {
+        return false;
+    }
+
+}
\ No newline at end of file
diff --git a/src/Authentication/Token.php b/src/Authentication/Token.php
new file mode 100644
index 0000000..bc5a98e
--- /dev/null
+++ b/src/Authentication/Token.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace WPDesk\ApiClient\Authentication;
+
+interface Token
+{
+    /**
+     * Get string to perform authentication
+     *
+     * @return string
+     */
+    public function getAuthString();
+
+    /**
+     * Is token expired or very soon to be expired?
+     *
+     * @return bool
+     */
+    public function isExpired();
+
+    /**
+     * Validates token signature
+     *
+     * @return bool
+     */
+    public function isSignatureValid();
+
+    /**
+     * @return string
+     */
+    public function __toString();
+}
\ No newline at end of file
diff --git a/src/Client/ApiClientOptions.php b/src/Client/ApiClientOptions.php
new file mode 100644
index 0000000..808f292
--- /dev/null
+++ b/src/Client/ApiClientOptions.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace WPDesk\ApiClient\Client;
+
+use Psr\Log\LoggerInterface;
+use WPDesk\ApiClient\Serializer\SerializerOptions;
+use WPDesk\HttpClient\HttpClientOptions;
+
+interface ApiClientOptions extends HttpClientOptions, SerializerOptions
+{
+
+    /**
+     * @return LoggerInterface
+     */
+    public function getLogger();
+
+    /**
+     * @return string
+     */
+    public function getApiUrl();
+
+    /**
+     * @return array
+     */
+    public function getDefaultRequestHeaders();
+
+    /**
+     * @return bool
+     */
+    public function isCachedClient();
+
+}
\ No newline at end of file
diff --git a/src/Client/CachedClient.php b/src/Client/CachedClient.php
new file mode 100644
index 0000000..3c6ee45
--- /dev/null
+++ b/src/Client/CachedClient.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace WPDesk\ApiClient\Client;
+
+use Psr\SimpleCache\CacheInterface;
+use WPDesk\Cache\CacheDispatcher;
+use WPDesk\Cache\CacheItemCreator;
+use WPDesk\Cache\CacheItemVerifier;
+use WPDesk\HttpClient\HttpClient;
+use WPDesk\ApiClient\Request\Request;
+use WPDesk\ApiClient\Response\Response;
+use WPDesk\ApiClient\Serializer\Serializer;
+
+class CachedClient implements Client, CacheItemCreator, CacheItemVerifier
+{
+
+    /** @var Client */
+    private $client;
+
+    /** @var CacheInterface */
+    private $cache;
+
+    /**
+     * @var CacheDispatcher
+     */
+    private $cacheDispatcher;
+
+    /**
+     * CachedClient constructor.
+     * @param Client $decorated Decorated client
+     * @param CacheInterface $cache
+     */
+    public function __construct(Client $decorated, CacheInterface $cache)
+    {
+        $this->client = $decorated;
+        $this->cache = $cache;
+        $this->cacheDispatcher = new CacheDispatcher($cache, [new RequestCacheInfoResolver()]);
+    }
+
+    /**
+     * Create item to cache.
+     *
+     * @param Request $request
+     * @return Response
+     */
+    public function createCacheItem($request)
+    {
+        return $this->client->sendRequest($request);
+    }
+
+    /**
+     * Verify cache item.
+     *
+     * @param $object
+     * @return Response;
+     */
+    public function getVerifiedItemOrNull($object)
+    {
+        if ($object instanceof Response) {
+            return $object;
+        }
+        return null;
+    }
+
+    /**
+     * Send request.
+     *
+     * @param Request $request
+     * @return mixed|Response
+     * @throws \Psr\SimpleCache\InvalidArgumentException
+     */
+    public function sendRequest(Request $request)
+    {
+        $response = $this->cacheDispatcher->dispatch($request, $this, $this);
+        return $response;
+    }
+
+    /**
+     * @return HttpClient
+     */
+    public function getHttpClient()
+    {
+        return $this->client->getHttpClient();
+    }
+
+    /**
+     * @param HttpClient $client
+     * @return mixed
+     */
+    public function setHttpClient(HttpClient $client)
+    {
+        return $this->client->setHttpClient($client);
+    }
+
+    /**
+     * @return Serializer
+     */
+    public function getSerializer()
+    {
+        return $this->client->getSerializer();
+    }
+
+    /**
+     * @return string
+     */
+    public function getApiUrl()
+    {
+        return $this->client->getApiUrl();
+    }
+
+}
\ No newline at end of file
diff --git a/src/Client/Client.php b/src/Client/Client.php
new file mode 100644
index 0000000..c3372f9
--- /dev/null
+++ b/src/Client/Client.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace WPDesk\ApiClient\Client;
+
+use WPDesk\HttpClient\HttpClient;
+use WPDesk\ApiClient\Request\Request;
+use WPDesk\ApiClient\Response\Response;
+use WPDesk\ApiClient\Serializer\Serializer;
+
+interface Client
+{
+    /**
+     * Send given request trough HttpClient
+     *
+     * @param Request $request
+     * @return Response
+     */
+    public function sendRequest(Request $request);
+
+    /**
+     * @return HttpClient
+     */
+    public function getHttpClient();
+
+    /**
+     * @param HttpClient $client
+     */
+    public function setHttpClient(HttpClient $client);
+
+    /**
+     * @return Serializer
+     */
+    public function getSerializer();
+
+    /**
+     * Returns api url. Always without ending /
+     *
+     * @return string
+     */
+    public function getApiUrl();
+}
\ No newline at end of file
diff --git a/src/Client/ClientFactory.php b/src/Client/ClientFactory.php
new file mode 100644
index 0000000..81ad8aa
--- /dev/null
+++ b/src/Client/ClientFactory.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace WPDesk\ApiClient\Client;
+
+use WPDesk\Cache\WordpressCache;
+use WPDesk\HttpClient\HttpClientFactory;
+use WPDesk\ApiClient\Serializer\SerializerFactory;
+
+class ClientFactory
+{
+    /**
+     * @param ApiClientOptions $options
+     * @return Client
+     */
+    public function createClient(ApiClientOptions $options)
+    {
+        $httpClientFactory = new HttpClientFactory();
+        $serializerFactory = new SerializerFactory();
+
+        $client = new ClientImplementation(
+            $httpClientFactory->createClient($options),
+            $serializerFactory->createSerializer($options),
+            $options->getLogger(),
+            $options->getApiUrl(),
+            $options->getDefaultRequestHeaders()
+        );
+
+        if ($options->isCachedClient()) {
+            $client = new CachedClient($client, new WordpressCache());
+        }
+
+        return $client;
+    }
+}
\ No newline at end of file
diff --git a/src/Client/ClientImplementation.php b/src/Client/ClientImplementation.php
new file mode 100644
index 0000000..feaeb75
--- /dev/null
+++ b/src/Client/ClientImplementation.php
@@ -0,0 +1,214 @@
+<?php
+
+namespace WPDesk\ApiClient\Client;
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use WPDesk\HttpClient\HttpClient;
+use WPDesk\HttpClient\HttpClientResponse;
+use WPDesk\ApiClient\Request\Request;
+use WPDesk\ApiClient\Response\RawResponse;
+use WPDesk\ApiClient\Response\Response;
+use WPDesk\ApiClient\Serializer\Serializer;
+use WPDesk\HttpClient\HttpClientRequestException;
+
+class ClientImplementation implements Client, LoggerAwareInterface
+{
+    const CLIENT_VERSION = '1.6.5';
+
+    const DEFAULT_TIMEOUT = 10;
+
+    /** @var HttpClient */
+    private $client;
+
+    /** @var Serializer */
+    private $serializer;
+
+    /** @var LoggerInterface */
+    private $logger;
+
+    /** @var string */
+    private $apiUrl;
+
+    /** @var array */
+    private $defaultRequestHeaders;
+
+    /**
+     * Client constructor.
+     * @param HttpClient $client
+     * @param Serializer $serializer
+     * @param LoggerInterface $logger
+     * @param string $apiUri
+     * @param array $defaultRequestHeaders
+     */
+    public function __construct(
+        HttpClient $client,
+        Serializer $serializer,
+        LoggerInterface $logger,
+        $apiUri,
+        array $defaultRequestHeaders
+    ) {
+        $this->client = $client;
+        $this->serializer = $serializer;
+        $this->logger = $logger;
+        $this->apiUrl = $apiUri;
+        $this->defaultRequestHeaders = $defaultRequestHeaders;
+    }
+
+    /**
+     * Send given request trough HttpClient
+     *
+     * @param Request $request
+     * @throws HttpClientRequestException
+     * @return Response
+     */
+    public function sendRequest(Request $request)
+    {
+        $this->logger->debug("Sends request with METHOD: {$request->getMethod()}; to ENDPOINT {$request->getEndpoint()}",
+            $this->getLoggerContext());
+        try {
+            $httpResponse = $this->client->send(
+                $fullUrl = $this->prepareFullUrl($request),
+                $method = $request->getMethod(),
+                $body = $this->prepareRequestBody($request),
+                $headers = $this->prepareRequestHeaders($request), self::DEFAULT_TIMEOUT
+            );
+
+            $this->logger->debug(
+                "Sent request with: URL: {$fullUrl};\n METHOD: {$method};\n BODY: {$body};\n"
+                . "HEADERS: " . json_encode($headers) . "\n\n and got response as CODE: {$httpResponse->getResponseCode()};\n"
+                . "with RESPONSE BODY {$httpResponse->getBody()}",
+                $this->getLoggerContext());
+
+
+            return $this->mapHttpResponseToApiResponse($httpResponse);
+        } catch (HttpClientRequestException $e) {
+            $this->logger->error("Exception {$e->getMessage()}; {$e->getCode()} occurred while sending request");
+            throw $e;
+        }
+    }
+
+    /**
+     * Returns full request url with endpoint
+     *
+     * @param Request $request
+     * @return string
+     */
+    private function prepareFullUrl(Request $request)
+    {
+        $endpoint = $request->getEndpoint();
+        if (strpos('http', $endpoint) === 0) {
+            return $endpoint;
+        }
+        return $this->getApiUrl() . $endpoint;
+    }
+
+    /**
+     * Map response from http client to api response using serializer
+     *
+     * @param HttpClientResponse $response
+     * @return RawResponse
+     */
+    private function mapHttpResponseToApiResponse(HttpClientResponse $response)
+    {
+        $apiResponse = new RawResponse(
+            $this->serializer->unserialize($response->getBody()),
+            $response->getResponseCode(),
+            $response->getHeaders()
+        );
+
+        return $apiResponse;
+    }
+
+    /**
+     * Prepare serialized request body
+     *
+     * @param Request $request
+     * @return string
+     */
+    private function prepareRequestBody(Request $request)
+    {
+        return $this->serializer->serialize($request->getBody());
+    }
+
+    /**
+     * Prepares array of http headers
+     *
+     * @param Request $request
+     * @return array
+     */
+    private function prepareRequestHeaders(Request $request)
+    {
+        $headers = array(
+            'User-Agent' => 'saas-client-' . self::CLIENT_VERSION,
+            'Accept-Encoding' => '*',
+            'Content-Type' => $this->serializer->getMime()
+        );
+        $headers = array_merge($this->defaultRequestHeaders, $headers);
+        return array_merge($headers, $request->getHeaders());
+    }
+
+    /**
+     * @return HttpClient
+     */
+    public function getHttpClient()
+    {
+        return $this->client;
+    }
+
+    /**
+     * @param HttpClient $client
+     */
+    public function setHttpClient(HttpClient $client)
+    {
+        $this->client = $client;
+    }
+
+    /**
+     * @return Serializer
+     */
+    public function getSerializer()
+    {
+        return $this->serializer;
+    }
+
+    /**
+     * Returns api url. Always without ending /
+     *
+     * @return string
+     */
+    public function getApiUrl()
+    {
+        return trim($this->apiUrl, '/');
+    }
+
+    /**
+     * Sets logger
+     *
+     * @param LoggerInterface $logger
+     */
+    public function setLogger(LoggerInterface $logger)
+    {
+        $this->logger = $logger;
+    }
+
+    /**
+     * Returns logger context for
+     *
+     * @param string $additional_context Optional additional context
+     * @return array
+     */
+    protected function getLoggerContext($additional_context = '')
+    {
+        $context = [
+            Platform::LIBARY_LOGIN_CONTEXT,
+            self::class
+        ];
+        if ($additional_context !== '') {
+            $context[] = $additional_context;
+        }
+        return $context;
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/Client/RequestCacheInfoResolver.php b/src/Client/RequestCacheInfoResolver.php
new file mode 100644
index 0000000..43a0b7e
--- /dev/null
+++ b/src/Client/RequestCacheInfoResolver.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace WPDesk\ApiClient\Client;
+
+use WPDesk\Cache\CacheInfoResolver;
+use WPDesk\Cache\HowToCache;
+use WPDesk\SaasPlatformClient\Request\AuthRequest;
+use WPDesk\SaasPlatformClient\Request\BasicRequest;
+use WPDesk\SaasPlatformClient\Request\Request;
+use WPDesk\SaasPlatformClient\Request\ShippingServicesSettings\PutSettingsRequest;
+use WPDesk\SaasPlatformClient\Request\Status\GetStatusRequest;
+use WPDesk\SaasPlatformClient\Response\ApiResponse;
+use WPDesk\SaasPlatformClient\Response\RawResponse;
+
+class RequestCacheInfoResolver implements CacheInfoResolver
+{
+
+    const DEFAULT_CACHE_TTL    = 86400; //24 hours
+    const CACHE_TTL_ONE_MINUTE = 60;
+
+    const OPTION_FS_SAAS_PLATFORM_VERSION_HASH = 'fs-saas-platform-version-hash';
+
+    /**
+     *
+     * @param Request $request
+     *
+     * @return bool
+     */
+    private function prepareCacheKey($request)
+    {
+        return md5($request->getEndpoint());
+    }
+
+    /**
+     *
+     * @param Request $request
+     *
+     * @return bool
+     */
+    public function isSupported($request)
+    {
+        if ($request instanceof BasicRequest) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     *
+     * @param Request $request
+     *
+     * @return bool
+     */
+    public function shouldCache($request)
+    {
+        if ($request instanceof ConnectKeyInfoRequest) {
+            return false;
+        }
+        if ($request instanceof GetStatusRequest) {
+            return false;
+        }
+        if ($request instanceof BasicRequest) {
+            if ('GET' === $request->getMethod()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     *
+     * @param Request $request
+     *
+     * @return HowToCache
+     */
+    public function prepareHowToCache($request)
+    {
+        $howToCache = new HowToCache($this->prepareCacheKey($request), self::DEFAULT_CACHE_TTL);
+        return $howToCache;
+    }
+
+    /**
+     * @param ApiResponse $response
+     *
+     * @return bool
+     */
+    private function isPlatformVersionFromResponseChanged(ApiResponse $response)
+    {
+        $stored_hash = get_option(self::OPTION_FS_SAAS_PLATFORM_VERSION_HASH, '');
+        if ($stored_hash !== $response->getPlatformVersionHash()) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * @param ApiResponse $response
+     */
+    private function storePlatformVersionHashFromResponse(ApiResponse $response)
+    {
+        update_option(self::OPTION_FS_SAAS_PLATFORM_VERSION_HASH, $response->getPlatformVersionHash());
+    }
+
+    /**
+     *
+     * @param Request $request
+     * @param mixed $item
+     *
+     * @return bool
+     */
+    public function shouldClearCache($request, $item)
+    {
+        if ($request instanceof PutSettingsRequest) {
+            return true;
+        }
+        if ($item instanceof ApiResponse && $this->isPlatformVersionFromResponseChanged($item)) {
+            $this->storePlatformVersionHashFromResponse($item);
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     *
+     * @param Request $request
+     * @param mixed $item
+     *
+     * @return string[]
+     */
+    public function shouldClearKeys($request, $item)
+    {
+        if ('GET' !== $request->getMethod()) {
+            return [$this->prepareCacheKey($request)];
+        }
+        return [];
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/Request/BasicRequest.php b/src/Request/BasicRequest.php
new file mode 100644
index 0000000..ae0f599
--- /dev/null
+++ b/src/Request/BasicRequest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace WPDesk\ApiClient\Request;
+
+
+class BasicRequest implements Request
+{
+    /** @var string */
+    protected $method;
+
+    /** @var array */
+    protected $data;
+
+    /** @var string */
+    protected $endPoint;
+
+    /**
+     * @return string
+     */
+    public function getMethod()
+    {
+        return $this->method;
+    }
+
+    /**
+     * Return endpoint in format /[^/]+/
+     *
+     * @return string
+     */
+    public function getEndpoint()
+    {
+        return '/' . trim($this->endPoint, '/');
+    }
+
+    /**
+     * Returns array of http headers
+     *
+     * @return array
+     */
+    public function getHeaders()
+    {
+        return array();
+    }
+
+    /**
+     * Return unserialized request body as array
+     *
+     * @return array
+     */
+    public function getBody()
+    {
+        return $this->data;
+    }
+}
\ No newline at end of file
diff --git a/src/Request/Request.php b/src/Request/Request.php
new file mode 100644
index 0000000..4789c80
--- /dev/null
+++ b/src/Request/Request.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace WPDesk\ApiClient\Request;
+
+
+interface Request
+{
+    /**
+     * @return string
+     */
+    public function getMethod();
+
+    /**
+     * @return array
+     */
+    public function getHeaders();
+
+    /**
+     * @return array
+     */
+    public function getBody();
+
+    /**
+     * @return string
+     */
+    public function getEndpoint();
+}
\ No newline at end of file
diff --git a/src/Response/ApiResponse.php b/src/Response/ApiResponse.php
new file mode 100644
index 0000000..b782720
--- /dev/null
+++ b/src/Response/ApiResponse.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace WPDesk\ApiClient\Response;
+
+
+interface ApiResponse extends Response
+{
+    /**
+     * Get links structure to the other request
+     *
+     * @return array
+     */
+    public function getLinks();
+
+    /**
+     * Is it a BAD REQUEST response
+     *
+     * @return bool
+     */
+    public function isBadRequest();
+
+    /**
+     * Is it a FATAL ERROR response
+     *
+     * @return bool
+     */
+    public function isServerFatalError();
+
+    /**
+     * Is requested resource exists
+     *
+     * @return bool
+     */
+    public function isNotExists();
+}
\ No newline at end of file
diff --git a/src/Response/AuthApiResponse.php b/src/Response/AuthApiResponse.php
new file mode 100644
index 0000000..86f34fa
--- /dev/null
+++ b/src/Response/AuthApiResponse.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace WPDesk\ApiClient\Response;
+
+use WPDesk\ApiClient\Response\Traits\AuthApiResponseDecorator;
+
+class AuthApiResponse implements ApiResponse
+{
+    const RESPONSE_CODE_BAD_CREDENTIALS = 401;
+
+    const RESPONSE_CODE_NOT_EXISTS = 404;
+
+    use AuthApiResponseDecorator;
+}
\ No newline at end of file
diff --git a/src/Response/ProtectedResponse.php b/src/Response/ProtectedResponse.php
new file mode 100644
index 0000000..c5195d0
--- /dev/null
+++ b/src/Response/ProtectedResponse.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace WPDesk\ApiClient\Response;
+
+use WPDesk\ApiClient\Response\Exception\TriedExtractDataFromErrorResponse;
+use WPDesk\ApiClient\Response\Traits\ApiResponseDecorator;
+
+/**
+ * Response is protected in a way so when you try to get body of the response when an error occured you will get an exception
+ *
+ * Class ProtectedResponse
+ * @package WPDesk\ApiClient\Response
+ */
+class ProtectedResponse implements Response
+{
+    use ApiResponseDecorator;
+
+    public function getResponseBody()
+    {
+        if ($this->isError()) {
+            throw TriedExtractDataFromErrorResponse::createWithClassInfo(get_class($this->rawResponse), $this->getResponseCode());
+        }
+        return $this->rawResponse->getResponseBody();
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/Response/RawResponse.php b/src/Response/RawResponse.php
new file mode 100644
index 0000000..3662483
--- /dev/null
+++ b/src/Response/RawResponse.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace WPDesk\ApiClient\Response;
+
+class RawResponse implements Response
+{
+    const RESPONSE_CODE_SUCCESS = 200;
+    const RESPONSE_CODE_CREATED = 201;
+    const RESPONSE_CODE_ERROR_BAD_REQUEST = 400;
+    const RESPONSE_CODE_DOMAIN_NOT_ALLOWED = 462;
+    const RESPONSE_CODE_ERROR_FATAL = 500;
+    const RESPONSE_CODE_MAINTENANCE = 503;
+    const HEADER_X_PLATFORM_VERSION_HASH = 'X-Platform-Version-Hash';
+
+    /** @var array */
+    private $data;
+
+    /** @var int */
+    private $code;
+
+    /** @var array */
+    private $headers;
+
+    /**
+     * RawResponse constructor.
+     * @param array $body
+     * @param int $code
+     * @param array $headers
+     */
+    public function __construct(array $body, $code, array $headers)
+    {
+        $this->data = $body;
+        $this->code = (int)$code;
+        $this->headers = $headers;
+    }
+
+    /**
+     * Returns response http code
+     *
+     * @return int
+     */
+    public function getResponseCode()
+    {
+        return $this->code;
+    }
+
+    /**
+     * Returns response body as array
+     *
+     * @return array
+     */
+    public function getResponseBody()
+    {
+        return $this->data;
+    }
+
+    /**
+     * Returns response body as array
+     *
+     * @return array
+     */
+    public function getResponseErrorBody()
+    {
+        return $this->data;
+    }
+
+    /**
+     * Returns response body as array
+     *
+     * @return array
+     */
+    public function getResponseHeaders()
+    {
+        return $this->headers;
+    }
+
+    /**
+     * Is any error occured
+     *
+     * @return bool
+     */
+    public function isError()
+    {
+        $code = $this->getResponseCode();
+        return ( $code < 200 || $code >= 300 ) && !$this->isMaintenance();
+    }
+
+    /**
+     * Is maintenance.
+     *
+     * @return bool
+     */
+    public function isMaintenance()
+    {
+        $code = $this->getResponseCode();
+        return self::RESPONSE_CODE_MAINTENANCE === $code;
+    }
+
+    /**
+     * Get platform version hash string.
+     *
+     * @return bool|string
+     */
+    public function getPlatformVersionHash()
+    {
+        if (isset($this->headers[self::HEADER_X_PLATFORM_VERSION_HASH])) {
+            return $this->headers[self::HEADER_X_PLATFORM_VERSION_HASH];
+        }
+        return false;
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/Response/Response.php b/src/Response/Response.php
new file mode 100644
index 0000000..296b6fd
--- /dev/null
+++ b/src/Response/Response.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace WPDesk\ApiClient\Response;
+
+
+interface Response
+{
+    /**
+     * @return int
+     */
+    public function getResponseCode();
+
+    /** @return array */
+    public function getResponseBody();
+
+    /** @return array */
+    public function getResponseHeaders();
+
+    /** @return array */
+    public function getResponseErrorBody();
+
+    /**
+     * Is any error occured
+     *
+     * @return bool
+     */
+    public function isError();
+
+    /**
+     * Is maintenance
+     *
+     * @return bool
+     */
+    public function isMaintenance();
+
+    /**
+     * Get platform version hash string.
+     *
+     * @return bool|string
+     */
+    public function getPlatformVersionHash();
+
+}
\ No newline at end of file
diff --git a/src/Response/Traits/ApiResponseDecorator.php b/src/Response/Traits/ApiResponseDecorator.php
new file mode 100644
index 0000000..0960c90
--- /dev/null
+++ b/src/Response/Traits/ApiResponseDecorator.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace WPDesk\ApiClient\Response\Traits;
+
+use WPDesk\ApiClient\Response\AuthApiResponse;
+use WPDesk\ApiClient\Response\RawResponse;
+use WPDesk\ApiClient\Response\Response;
+
+trait ApiResponseDecorator
+{
+
+    /** @var RawResponse */
+    private $rawResponse;
+
+    /**
+     * RawResponseDecorator constructor.
+     * @param Response $rawResponse
+     */
+    public function __construct(Response $rawResponse)
+    {
+        $this->rawResponse = $rawResponse;
+    }
+
+    /**
+     * Returns response http code
+     *
+     * @return int
+     */
+    public function getResponseCode()
+    {
+        return $this->rawResponse->getResponseCode();
+    }
+
+    /**
+     * Returns response body as array
+     *
+     * @return array
+     */
+    public function getResponseBody()
+    {
+        return $this->rawResponse->getResponseBody();
+    }
+
+    /**
+     * Returns response body as array
+     *
+     * @return array
+     */
+    public function getResponseErrorBody()
+    {
+        return $this->rawResponse->getResponseErrorBody();
+    }
+
+    /**
+     * Returns response body as array
+     *
+     * @return array
+     */
+    public function getResponseHeaders()
+    {
+        return $this->rawResponse->getResponseHeaders();
+    }
+
+    /**
+     * Get links structure to the other request
+     *
+     * @return array
+     */
+    public function getLinks()
+    {
+        $body = $this->getResponseBody();
+        return $body['_links'];
+    }
+
+    /**
+     * Is it a BAD REQUEST response
+     *
+     * @return bool
+     */
+    public function isBadRequest()
+    {
+        return $this->getResponseCode() === RawResponse::RESPONSE_CODE_ERROR_BAD_REQUEST;
+    }
+
+    /**
+     * Is it a DOMAIN NOT ALLOWED response
+     *
+     * @return bool
+     */
+    public function isDomainNotAllowed()
+    {
+        return $this->getResponseCode() === RawResponse::RESPONSE_CODE_DOMAIN_NOT_ALLOWED;
+    }
+
+    /**
+     * Is it a FATAL ERROR response
+     *
+     * @return bool
+     */
+    public function isServerFatalError()
+    {
+        return $this->getResponseCode() === RawResponse::RESPONSE_CODE_ERROR_FATAL;
+    }
+
+    /**
+     * Is any error occured
+     *
+     * @return bool
+     */
+    public function isError()
+    {
+        return $this->rawResponse->isError();
+    }
+
+    /**
+     * Is requested resource exists
+     *
+     * @return bool
+     */
+    public function isNotExists()
+    {
+        return $this->getResponseCode() === AuthApiResponse::RESPONSE_CODE_NOT_EXISTS;
+    }
+
+    /**
+     * Is maintenance.
+     *
+     * @return bool
+     */
+    public function isMaintenance()
+    {
+        return $this->rawResponse->isMaintenance();
+    }
+
+    /**
+     * Get platform version hash string.
+     *
+     * @return bool|string
+     */
+    public function getPlatformVersionHash()
+    {
+        return $this->rawResponse->getPlatformVersionHash();
+    }
+
+    }
\ No newline at end of file
diff --git a/src/Response/Traits/AuthApiResponseDecorator.php b/src/Response/Traits/AuthApiResponseDecorator.php
new file mode 100644
index 0000000..a052865
--- /dev/null
+++ b/src/Response/Traits/AuthApiResponseDecorator.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace WPDesk\ApiClient\Response\Traits;
+
+use WPDesk\ApiClient\Response\AuthApiResponse;
+
+trait AuthApiResponseDecorator
+{
+    use ApiResponseDecorator;
+
+    /**
+     * @return bool
+     */
+    public function isBadCredentials()
+    {
+        return $this->getResponseCode() === AuthApiResponse::RESPONSE_CODE_BAD_CREDENTIALS;
+    }
+
+    /**
+     * Is bad credential because token expires
+     *
+     * @return bool
+     */
+    public function isTokenExpired()
+    {
+        return $this->isBadCredentials();
+    }
+
+    /**
+     * Is bad credential because token is invalid
+     *
+     * @return bool
+     */
+    public function isTokenInvalid()
+    {
+        return $this->isBadCredentials();
+    }
+}
\ No newline at end of file
diff --git a/src/Response/Traits/PagedListImplementation.php b/src/Response/Traits/PagedListImplementation.php
new file mode 100644
index 0000000..2328647
--- /dev/null
+++ b/src/Response/Traits/PagedListImplementation.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace WPDesk\ApiClient\Response\Traits;
+
+trait PagedListImplementation
+{
+    /*
+     * @return array
+     */
+    public function getRawPage()
+    {
+        $body = $this->getResponseBody();
+        if ($body['_embedded'] !== null && $body['_embedded']['item'] !== null) {
+            return $body['_embedded']['item'];
+        }
+        return [];
+    }
+
+    /**
+     * @return int
+     */
+    public function getPageCount()
+    {
+        return (int)floor($this->getItemCount() / $this->getItemsPerPage());
+    }
+
+    /**
+     * @return int
+     */
+    public function getItemsPerPage()
+    {
+        $body = $this->getResponseBody();
+        return (int)$body['itemsPerPage'];
+    }
+
+    /**
+     * @return int
+     */
+    public function getItemCount()
+    {
+        $body = $this->getResponseBody();
+        return (int)$body['totalItems'];
+    }
+}
\ No newline at end of file
diff --git a/src/Serializer/Exception/CannotUnserializeException.php b/src/Serializer/Exception/CannotUnserializeException.php
new file mode 100644
index 0000000..0d60348
--- /dev/null
+++ b/src/Serializer/Exception/CannotUnserializeException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace WPDesk\ApiClient\Serializer\Exception;
+
+/**
+ * Thrown when serializer cannot unserialize string data
+ *
+ * @package WPDesk\ApiClient\Serializer\Exception
+ */
+class CannotUnserializeException extends \RuntimeException
+{
+
+}
\ No newline at end of file
diff --git a/src/Serializer/JsonSerializer.php b/src/Serializer/JsonSerializer.php
new file mode 100644
index 0000000..55fa230
--- /dev/null
+++ b/src/Serializer/JsonSerializer.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace WPDesk\ApiClient\Serializer;
+
+use WPDesk\ApiClient\Serializer\Exception\CannotUnserializeException;
+
+class JsonSerializer implements Serializer
+{
+
+    /**
+     * Convert data to string
+     *
+     * @param mixed $data
+     * @return string
+     */
+    public function serialize($data)
+    {
+        return json_encode($data, JSON_FORCE_OBJECT);
+    }
+
+    /**
+     * Convert string to php data
+     *
+     * @param string $data
+     * @return mixed
+     */
+    public function unserialize($data)
+    {
+        $unserializedResult = json_decode($data, true);
+        if ($unserializedResult === null) {
+            throw new CannotUnserializeException("Cannot unserialize data: {$data}");
+        }
+
+        return $unserializedResult;
+    }
+
+    /**
+     * @return string
+     */
+    public function getMime()
+    {
+        return 'application/json';
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/Serializer/Serializer.php b/src/Serializer/Serializer.php
new file mode 100644
index 0000000..1faa8f9
--- /dev/null
+++ b/src/Serializer/Serializer.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace WPDesk\ApiClient\Serializer;
+
+interface Serializer
+{
+    /**
+     * @param mixed $data
+     * @return string
+     */
+    public function serialize($data);
+
+    /**
+     * @param string $data
+     * @return mixed
+     */
+    public function unserialize($data);
+
+    /**
+     * @return string
+     */
+    public function getMime();
+}
\ No newline at end of file
diff --git a/src/Serializer/SerializerFactory.php b/src/Serializer/SerializerFactory.php
new file mode 100644
index 0000000..532099e
--- /dev/null
+++ b/src/Serializer/SerializerFactory.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace WPDesk\ApiClient\Serializer;
+
+class SerializerFactory
+{
+    /**
+     * @param SerializerOptions $options
+     * @return Serializer
+     */
+    public function createSerializer(SerializerOptions $options)
+    {
+        $className = $options->getSerializerClass();
+        return new $className;
+    }
+}
\ No newline at end of file
diff --git a/src/Serializer/SerializerOptions.php b/src/Serializer/SerializerOptions.php
new file mode 100644
index 0000000..d1441bc
--- /dev/null
+++ b/src/Serializer/SerializerOptions.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace WPDesk\ApiClient\Serializer;
+
+interface SerializerOptions
+{
+    /**
+     * @return string
+     */
+    public function getSerializerClass();
+}
\ No newline at end of file
diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml
new file mode 100644
index 0000000..2a86b03
--- /dev/null
+++ b/tests/docker-compose.yaml
@@ -0,0 +1,172 @@
+version: '2.0'
+
+services:
+
+  wordpress:
+    image: wpdesknet/phpunit-woocommerce:0-0
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql0
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql0
+
+  wordpress-0-1:
+    image: wpdesknet/phpunit-woocommerce:0-1
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql1
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql1
+
+  wordpress-0-2:
+    image: wpdesknet/phpunit-woocommerce:0-2
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql2
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql2
+
+  wordpress-0-3:
+    image: wpdesknet/phpunit-woocommerce:0-3
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql3
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql3
+
+  wordpress-0-4:
+    image: wpdesknet/phpunit-woocommerce:0-4
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql4
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql4
+
+  wordpress-0-5:
+    image: wpdesknet/phpunit-woocommerce:0-5
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql5
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql5
+
+  wordpress-1-0:
+    image: wpdesknet/phpunit-woocommerce:1-0
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql0
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql0
+
+  wordpress-2-0:
+    image: wpdesknet/phpunit-woocommerce:2-0
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql0
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql0
+
+  wordpress-3-0:
+    image: wpdesknet/phpunit-woocommerce:3-0
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql0
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql0
+
+  wordpress-4-0:
+    image: wpdesknet/phpunit-woocommerce:4-0
+    volumes:
+    - .././:/opt/project
+    depends_on:
+    - mysql0
+    environment:
+      WORDPRESS_DB_NAME: wptest
+      WORDPRESS_DB_USER: mysql
+      WORDPRESS_DB_PASSWORD: mysql
+      WORDPRESS_DB_HOST: mysql0
+
+  mysql0:
+    image: mysql:5.7
+    environment:
+      MYSQL_ROOT_PASSWORD: mysql
+      MYSQL_DATABASE: wptest
+      MYSQL_USER: mysql
+      MYSQL_PASSWORD: mysql
+
+  mysql1:
+    image: mysql:5.7
+    environment:
+      MYSQL_ROOT_PASSWORD: mysql
+      MYSQL_DATABASE: wptest
+      MYSQL_USER: mysql
+      MYSQL_PASSWORD: mysql
+
+  mysql2:
+    image: mysql:5.7
+    environment:
+      MYSQL_ROOT_PASSWORD: mysql
+      MYSQL_DATABASE: wptest
+      MYSQL_USER: mysql
+      MYSQL_PASSWORD: mysql
+
+  mysql3:
+    image: mysql:5.7
+    environment:
+      MYSQL_ROOT_PASSWORD: mysql
+      MYSQL_DATABASE: wptest
+      MYSQL_USER: mysql
+      MYSQL_PASSWORD: mysql
+
+  mysql4:
+    image: mysql:5.7
+    environment:
+      MYSQL_ROOT_PASSWORD: mysql
+      MYSQL_DATABASE: wptest
+      MYSQL_USER: mysql
+      MYSQL_PASSWORD: mysql
+
+  mysql5:
+    image: mysql:5.7
+    environment:
+      MYSQL_ROOT_PASSWORD: mysql
+      MYSQL_DATABASE: wptest
+      MYSQL_USER: mysql
+      MYSQL_PASSWORD: mysql
+
diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php
new file mode 100644
index 0000000..a422fd9
--- /dev/null
+++ b/tests/integration/bootstrap.php
@@ -0,0 +1,28 @@
+<?php
+
+ini_set('error_reporting', E_ALL); // or error_reporting(E_ALL);
+ini_set('display_errors', '1');
+ini_set('display_startup_errors', '1');
+
+require_once __DIR__ . '/../../vendor/autoload.php';
+
+// disable xdebug backtrace
+if ( function_exists( 'xdebug_disable' ) ) {
+	xdebug_disable();
+}
+
+if ( getenv( 'PLUGIN_PATH' ) !== false ) {
+	define( 'PLUGIN_PATH', getenv( 'PLUGIN_PATH' ) );
+} else {
+	define( 'PLUGIN_PATH', __DIR__ . DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR );
+}
+
+require_once( getenv( 'WP_DEVELOP_DIR' ) . '/tests/phpunit/includes/functions.php' );
+
+tests_add_filter( 'muplugins_loaded', function () {
+}, 100 );
+
+putenv('WP_TESTS_DIR=' . getenv( 'WP_DEVELOP_DIR' ) . '/tests/phpunit');
+require_once( getenv( 'WC_DEVELOP_DIR' ) . '/tests/bootstrap.php' );
+
+do_action('plugins_loaded');
\ No newline at end of file
diff --git a/tests/unit/Authentication/TestJWTSaasToken.php b/tests/unit/Authentication/TestJWTSaasToken.php
new file mode 100644
index 0000000..526253d
--- /dev/null
+++ b/tests/unit/Authentication/TestJWTSaasToken.php
@@ -0,0 +1,64 @@
+<?php
+
+use WPDesk\ApiClient\Authentication\JWTSaasToken;
+use WPDesk\ApiClient\Authentication\JWTToken;
+
+class TestJWTSaasToken extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Provides tokens with info about expected shop id encoded inside
+     *
+     * @return array
+     */
+    public function provideTokensWithShopInfo()
+    {
+        return [
+            [
+                'token' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ5NDA2NjQsImV4cCI6MTUzNDk0NDI2NCwicm9sZXMiOlsiUk9MRV9VU0VSIiwiUk9MRV9TSE9QIl0sInVzZXJuYW1lIjozLCJpcCI6IjE3Mi4xOS4wLjEiLCJzaG9wIjoxfQ.rG6_U67zKTinkqR4324wOhS0YP9P9_2DH-OMsfijTPt408wNGwh5YKqkA4kglP5wMQQ80UGo7qyYd9R3fc465JMB9DQTbALATz_UMk8fr_-LWl9Gj8cuzQvEMQL2Saya_nggAn0bmWlVG27k6-6ezfBW7Hhb1d1GtoydbiYMA_ntLVPzrRjicvcIoftroadX6rsuCSHy-lLvHd1pj6E6eOEX2IcZXoa9eqd_3-j4dgszNUoPDag8bYXQzmIDIqyseBQ3eaQr69Tj-npTaMDjSyHwFQqtr57leX9aVVo8xPeF2ulrT4u4VgCv6kQt1Fao5_G6WLJ9FpJE19k_e4uebSwmDwiPoSnqU30tmIoVpcqfzxoNDKxfaK6am4HvfiQ6l_A2Cb7Wi9Wh5Px1A2a1NjFNKq-hxI3bVb30cKvacL29tYcjT-315RKv57onnCjVJWaGfzPJ44ZibB2VaVF9HO8Rjci8PJ35lF0NOw7YYb7rTQB0JOsLhHrwe9cjgKBsxwIAOVx1fFt6fq1RRJzUpcHKAx0f7kwGmHzXNwpNIexneBYg1Btz5UfW3yiztxggBPYvDfFAdLc5hfFURFhLtqaQzCklomRYBkbEkBGSt_5iz7HLYiiWuM51TzXgUMa631KwCuyBA675fwEMnwQ01YM51Et7NQgRcja737kSliM',
+                'shop' => 1
+            ],
+            [
+                'token' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ5NDEyMDAsImV4cCI6MTUzNDk0NDgwMCwicm9sZXMiOlsiUk9MRV9VU0VSIiwiUk9MRV9TSE9QIl0sInVzZXJuYW1lIjozLCJpcCI6IjE3Mi4xOS4wLjEiLCJzaG9wIjo1fQ.NAh6p40uSgyhcRkXxLSJhsGSn-mRAIYGsbtuMqSrplHk7LxrOQvvLBKfW0cZpl4y683ECGYqykJYmGb3nnWPKOQv5Pl2GExLItvzWuVju6KvkTi-_RD1xVXv6tCxkI6umcrJaI0bUWQhjLogrjNgIAR81DRbfG81lctIRzhP-CtADD9J7uEFrfWIGIbBgVOMdiw4nsAvqB9ECTDbnv5TRsxmOnyhm42mWxfpbrzd7WaRugIMWbysL2_5HXwqLV7jaaPlBHFpGs3cpgpRF0fsZ1VZeM7LAiiwvNuBwVsZbmD_ZKKdXzwL183RbsuSP4C_7gjH4UXEflRWRR6nzom_AyCFhqGrdu1obSNLiIeIt94p_VavOmwicwQKmuhOHGnYHVguTLYW2FcQIS2nMosswnXoZzTJcL3YwQQsMT6Xg-Da43bWZMIWvcK7d-nSvOGHW0OrHzWQRZOcH3RKANzPUEG5L0-KL55yacMbAPq6ykGc-I-_q1IDBxfxE2rSaxGKa72O60uwjX2Mafdhp9YWuESNm2Gvi09hYo3_dBUAHjaSdXOWqJhb8h1ND8c2IJNxltTIukz1fDdLkFvTttZi4g5rjsJoDN94Y3f-19qokY8lzhx8z3Zc4hgbNQ9p8CYrmjB6FDa59Psuxbd-ODwzNm_uLsn1z4U5uo68QHa5g50',
+                'shop' => 5
+            ],
+            [ // case with wpdesk and shop role
+                'token' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ5NDE0MzYsImV4cCI6MTUzNDk0NTAzNiwicm9sZXMiOlsiUk9MRV9XUERFU0siLCJST0xFX1NIT1AiXSwidXNlcm5hbWUiOjEsImlwIjoiMTcyLjE5LjAuMSIsInNob3AiOjJ9.pD9qQQJB_Mn6hSBcK5unEyrQ2n-3BgnzCRtk1V_u7BL0SKqgqILQN5NGJYpG2dO1Rb54pCNgAVzr6axu9f6ijmGetpHX_V1HSy7HOM4xdI8H7BaPHaK-2KUKTrVFM7lEp_sRc4KBkfSW4Ju2EH0e0Oty42EkIWeTb3J3npTHK_RjXI47xX5e_gZW8inRfJx24EhiSnlkMX77lIpXsqqCyRKqW64niLrQzKMNeSOJ_5HgBvzk9OyR9H5O8_s8EAliD24LvynoxgQOp9r0A78EIBRoqdZKJSS7kSgl_8eRFTGgxZBPGmyBKNWvxrd2XnRp19QPgymMi2kR0HJDWRemtSFHxYsjTrVVnpeiHjZ7WTKNawZPB-7ELPIHm_C_-GuJ4I1fnnM_uUna2rh6mr3KeRQF4LJGmDjesdHIpy8FDmepqsIcAAxpmAi6kJRtDr6QAxoh20zi0jCni1pPmLQyoDsau7U7l3Nev_STFbFiHBk-mLYbhNqV_MCcFY1-GeWynfehehWoVeGTShVoxXrDSWklPb58DaQKuwXJ8-kvmvWVhVqWGqF5l7qUj3D2CIxr4tXsQ6FUPkntvFw3S4GrLqAl-P7XoR5alRxSyC6KypYeY7pHRZBDDeoFWgCOJhEymWtKrcTt-Yzd2EL3vQYnwhYOp0hw1n-eXnE1WkaB4Wg',
+                'shop' => 2
+            ]
+        ];
+    }
+
+    /**
+     * Provides tokens when can't get info about shop
+     *
+     * @return array
+     */
+    public function provideTokensWithInvalidShopInfo()
+    {
+        return [
+            ['invalid' => 'siUk9MRV9XUERFU0siLCJST0xFX1NIT1AiXSwidXNlcm5hbWUiOjEsImlwIjoiMTcyLjE5LjAuMSIsInNob3AiOjJ9.qeroBbMHVgQWypDaF6FPmaZadu7D1u1JObCn9rXy44pGL1mWESdVCI6pU_MajeCJsQ-V3emWMOfqHl5AIiH7BZb2iW04ahGJjJTtRGaEfGH5ztyG-00TVzf51V0mtX3i3DuEEJd8F6LDHK8C51oqm6WkHVv__GhsqOlMz_WTDdst-IRUK-kgcUBhbYy-XzVxt4LADQhs4SsJqd-0wYPaobTFRQaHel69oGF_ymwJ4snla4uJxlfIHLtmYRArOIcDNKcPIOH8cMo-UJP9B15IxuOGO0M2hR74LNSmDUpIHmeQ24rEiwp73q2yhXYIHhIBdExDk7GAHFhOILIqa05D48V8fkPLr0TFVVCACvylJHwj3UsYPzI1WvhdrEHcGr1MC8bWB2KAC_RpB5Vvo7kPp0YXcUa7nbszL90v1njKpVrcSrpYG69a4Ym8ZXSdAS7jJmoSH-Xf3JXXi_6vtxtSsm-DG_zwd7VoWupBu1rG1h_ARW-Lambj_Ql2XZEAWAvdR6LqHkL5SyIZ7CnscbNY_VbYoyRHsQIwhzbaQdBSwBa4qu0VsEHtwdV2acbDZhojm-DMb4b7vr3znyw_WnHPSvjOGRXmW2KhD0bwwfrAJcquc0rL5N04FRVYXR7AnJjN-j1j0bfhvDN9a5W-yHidmp9_OZ0HVCqK5c0IIsiv-Ms'],
+            ['no shop id' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ5NDE5MjMsImV4cCI6MTUzNDk0NTUyMywicm9sZXMiOlsiUk9MRV9VU0VSIiwiUk9MRV9TSE9QIl0sInVzZXJuYW1lIjozLCJpcCI6IjE3Mi4xOS4wLjEifQ.S8Dkz9C7d0zHRn5_uRzjdBHH437JJZ4CdHTRc-Bwn1bnP9rj0SGn3AbCA53TlZjBl2EX0cigRlEMYsKEr0eQdQK8dellP0EjtPSaX5_5nEuU_H0X_6oJT8wYiS9N2jVm6W3UiKHMJBCR9JQfhjHYmMRx-NKhAi5xUgiGIWC7IWhNvLzHu52sRozkG2jTssnHGid087FdnQ1aG9c4dYY9piSw-5xBf8FruP9sDoWhTvFzESHIlwzgL5MNHQx-9HUH6caWoblpas959YqbD29h49YuCHLbifR1sMjSUWnkw69WLGDAFs8aKPCR5FsfKg3Rac0s957Cl3ES60eL7kWJ2mk1kRjxpFa6me1pWvh7Pt6lpDsJPLvTIbcZ_aDX5taOOiucL8YO-PbCB37Ffi2L4Am0-b4EGrqarBprjUWe2CJA9tCfjjFm0McOotDNVjLDmU7wAdHv8hXEOq4Kr2wd1z7suRqZii3VjMotoWFz9Cq4BTu1cs_Z_OLq5aC_uhnWq-SsKx3pdfL5tDXZ_CPqDCW0_3692FT8Z_puk1UUtMI_I_GkY_gp-npH72D8VcYdI-0j4LJoEtviGk6-0Lk47CiygGw79TaFjcEhwxPoYD_7MwMEw7vJiYYO8PVptlSuQy7j2-cPI0XPHlu19tVzA97ilqOlRAxt_SihXMfSoyo'],
+            ['no shop role' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ5NDIwNjEsImV4cCI6MTUzNDk0NTY2MSwicm9sZXMiOlsiUk9MRV9VU0VSIl0sInVzZXJuYW1lIjozLCJpcCI6IjE3Mi4xOS4wLjEiLCJzaG9wIjo1fQ.PS5SQixw9k9_-Waxho4GKJ1QudCSzee6gy9_cFjWnVcBpMGH-PkHf5iDO5rWwqQSsKpBgMi5m-FFCYoelHa5q1-XAn6FkRlj0aXNcQZCY6YGfqIaDeCP-0DEVoSqfaceFXLZbTyPz4aKrurONjWdZwuw9uull6Ho4Nq1B_E5QTpJBYehmebgWnS5OjHDjJVW0UrknwKHiRSohP-vCA5P2HQGZpZXrUCkLFcL2PqCCchORuHUQr3xjn2OleE4Jt1RsD5Lymo3m8s6WkRw9GepWV7-vJ0WDh3Mu_irkstZlCyujfh0PNnRSEyr9jshW2OXnDX_l0dLzsd5n6pPX1OVa58FpkZNb3s7hAsVxQ2Dd9GUg2o-qMZDCP9LbXn_CGUqiyXo4iIdal4balF0DpUGNEfggwqnL4leTsHqUirsN1bhLLhRmkAYooCfFuEMA9F5G66zF8Vwl5AuZSz8Nrj-T0krM54H5ykhzVttpWyRA9TcVywKAC9E2DFDOMfajtJA_LcMv5xg-m0uhhxlGk25HAY8yVWQdPfBp5KIq2jO3vLPzVmBDPFjtDgwiUXvXVakFS7AmtI8DRWh8dq92F2dGDV67uZ_WK5kqBQEtO1abfM5EF0zcQ9r_E4KqjLJfXyCyLMucw8CUdsE8MEcCfjpE06_mSuRMW1dpoSPJWGXE5Y'],
+            ['no whole shop info' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ5NDIwMjksImV4cCI6MTUzNDk0NTYyOSwicm9sZXMiOlsiUk9MRV9VU0VSIl0sInVzZXJuYW1lIjozLCJpcCI6IjE3Mi4xOS4wLjEifQ.XTvcWiKWLda4TnHhWnqrOKCBseiHmjnDYgmPCKhX6NrEYMu4mN0puKgnoeOWJVZHBjFQ5X--khMFKNKU4e7Zgc6MWgnSc5niKyZpr73D-PVfUkOG81we2-DSteik2i3F2R2cTSIUbYMJKeqe7fQ0qTZ3-7s6kNgKAkIhGraQrbbxi-FTp4uNbUrDwcDEyBZ_T9zmcR9jXsKLVJszHdmc8K_eVYwj2jOR8bouGOqpCHwlGz0bEpcSPqXJ_DsotVeM91QIBls4JzImTFbYr06N1GdG4oFzMNWqVhO9iJ4-ElN_COPLp1ou0i1Ha0ZedYajI2CJrhailbF5ybZd5x08tiDoMa26aVpH09wOe_P4zYno0ksMnZ66WpPcnBgnPA_dlgcqUUkc93igicD8XCKEBokEyF16IzI3A8Y85FEuwkIj-JadM8ZTJqHErxNbhZyuEGqWvQfwhawade-3CxOUz3krbG8LxhNPBYrm2t8NQpRwwFoQPa0lvEdMiGo4JL0J7hW02shT2iGPOUwJ0_KfpXCU5ensd60Mou0GQhnUPhKvnA0CtvQn5JgX_TM5OKKCSvtcIEaW2pqq96--S8atnQp2GwZU0Ccs8R6HAfH61d8cwL8abwKvr6A5wQoskn28YPwWu8g9Tvt556i2oIvFWEjrFOnfQyhmQj1GCVMafFc'],
+        ];
+    }
+
+    /**
+     * @dataProvider provideTokensWithShopInfo
+     */
+    public function testValidShopInfoFromToken($token, $shop)
+    {
+        $saasToken = new JWTSaasToken(new JWTToken($token));
+        $this->assertTrue($saasToken->hasShopId(), 'Token should have shop id info');
+        $this->assertEquals($shop, $saasToken->getShopId(), 'Invalid shop id provided in token');
+    }
+
+    /**
+     * @dataProvider provideTokensWithInvalidShopInfo
+     */
+    public function testTokensWithInvalidShopInfo($token)
+    {
+        $saasToken = new JWTSaasToken(new JWTToken($token));
+        $this->assertFalse($saasToken->hasShopId(), 'Token should NOT have shop id info');
+    }
+}
\ No newline at end of file
diff --git a/tests/unit/Authentication/TestJWToken.php b/tests/unit/Authentication/TestJWToken.php
new file mode 100644
index 0000000..492f115
--- /dev/null
+++ b/tests/unit/Authentication/TestJWToken.php
@@ -0,0 +1,33 @@
+<?php
+
+use WPDesk\ApiClient\Authentication\JWTToken;
+
+class TestJWToken extends \PHPUnit\Framework\TestCase
+{
+
+    public function testExpiredToken()
+    {
+        $expiredToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ4NTc2OTEsImV4cCI6MTUzNDg2MTI5MSwicm9sZXMiOlsiUk9MRV9VU0VSIiwiUk9MRV9TSE9QIl0sInVzZXJuYW1lIjozLCJpcCI6IjE3Mi4xOS4wLjEiLCJzaG9wIjoxfQ.iB9u8f3uBdq1KhkkYPAVpPFSwvkK4CQypfFQIiM8N5acQSFxv-jyzD2guGs4HtLOdMvu4Dt5zkd4ZfQFJNT2b6k7i33FyU4AsEDAVtwHL-TcqsnomXn70CjB5Rhgd2LteFl6wPp3XonE4SZ6Oo3vtBZzSoNNkR6-7T3OseJMwoJ7qzlEFBixNXG6UTlXPJky_b-rbfhFORlInxVzvs4GJgkJM3F3Ugy4bLSSPtBYxWcwKnKkFE8L4Z87Pezp4v35aXqJFpvJ3zll0gJ1F32Z2vx1oCmu0jOHkzzmu3wA2u6gK0iNgp591M7MKH2_3HaLfFY06cLoLDN6TR6wmzSvZYmzSw8C68MUH7uGWyGRU9j4YtdL8Bom3v8D1J-IC4Jx6-QPE665nd1VgzHZb1TFkHseUx3kLF5Jhgq7095NJ79QTC-6XTW2bN-T-dbbFkvjCU-B-9Ti09uMUEn4Rtlt_lThbr_lA9Wyc4qXAecqCz6dAC2UTy-_KxLwvfNrQ4sSS4y3B8bPh8qJrI0EsIbi5nY20sgBT71abUG9DHmIzj4rA5YW_DOu7Cez0WhNoCHjhMEymATrD2cWYxFCPWQfTTBuoD1HZgVfwYI9B-aZPw669orNRGkBtP8Bm5ghmCZoRHpISiY7UoIzgOnVKhB-Qv43g1uRprk7nYnfVEglqXg';
+
+        $jwtToken = new JWTToken($expiredToken);
+        $this->assertTrue($jwtToken->isSignatureValid(), 'Signature should be valid');
+        $this->assertTrue($jwtToken->isExpired(), 'Token should be expired long ago');
+    }
+
+    public function testImmortalToken()
+    {
+        $tokenWithExpiredDateInFuture = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ5NDIyODgsImV4cCI6MzI1MDM2ODAwMDAsInJvbGVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfU0hPUCJdLCJ1c2VybmFtZSI6MywiaXAiOiIxNzIuMTkuMC4xIiwic2hvcCI6NX0.gWsHsCGm8iUc13lGc9PgUtwv-qIYWAl98-jdzOdzuQ_PwreLuEFonP-hutp0_IXZtHeE6e1XLID-Cgn0YaahLrxybVGBTbcVgTJVOMB9Fv-gP9lHYNqvBdfQRcdXiO6PYCBbfpezunhcmiML_ebFfprdoMn8kG3K-XbEkyRB7MOQg0-dZ35tncVCZDfzLh5fucFzteQmCcddIosKpqr-rjYjRRCAB-aTE1vbAulZa1_VmP17l6m64__QBjHW9r07OTq7QviayXpOB_4mBdxI26XjgDXANCzlejcka5Uh7AS03cP3zDP8Mc0VaVZxqUhwotQ93s0VtyMNyUn_nkMebAuwXkDOmgsQzJHhlPD8hs9vzmNbN6ymKwsSZ-E3q1yPTQwXzu-rERdCaNifAIKlUyTETlvE3aoN7M0JjdCIVHBkf81ygppMITYJ0eytjyqBg0wjdRlMCpHEtGVosqLe8sNZo7NeZ9tPexhZxWXQNRA6VyE1NwDYd9yPsKBSPrulFcvkgoGOuL98rfE9iizMDYD60G0eTgafnR_99-IH4yoBMIjf4UNVzAcw8revbSE-3bgmxpdjVTJtVXw9LH13BHdB6GN7KJDwH4NRoz_MQJMSBELU18zY2yjmkNLXYI3YArTE3OQ-qBzD7rgPrcJBmoWTswbNVnTl2csFVHdQZOo';
+
+        $jwtToken = new JWTToken($tokenWithExpiredDateInFuture);
+        $this->assertTrue($jwtToken->isSignatureValid(), 'Signature should be valid');
+        $this->assertFalse($jwtToken->isExpired(), 'Token should NOT be expired. Have 3000 years of live');
+    }
+
+    public function testStringValueAndBearer() {
+        $someToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1MzQ5NDIyODgsImV4cCI6MzI1MDM2ODAwMDAsInJvbGVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfU0hPUCJdLCJ1c2VybmFtZSI6MywiaXAiOiIxNzIuMTkuMC4xIiwic2hvcCI6NX0.gWsHsCGm8iUc13lGc9PgUtwv-qIYWAl98-jdzOdzuQ_PwreLuEFonP-hutp0_IXZtHeE6e1XLID-Cgn0YaahLrxybVGBTbcVgTJVOMB9Fv-gP9lHYNqvBdfQRcdXiO6PYCBbfpezunhcmiML_ebFfprdoMn8kG3K-XbEkyRB7MOQg0-dZ35tncVCZDfzLh5fucFzteQmCcddIosKpqr-rjYjRRCAB-aTE1vbAulZa1_VmP17l6m64__QBjHW9r07OTq7QviayXpOB_4mBdxI26XjgDXANCzlejcka5Uh7AS03cP3zDP8Mc0VaVZxqUhwotQ93s0VtyMNyUn_nkMebAuwXkDOmgsQzJHhlPD8hs9vzmNbN6ymKwsSZ-E3q1yPTQwXzu-rERdCaNifAIKlUyTETlvE3aoN7M0JjdCIVHBkf81ygppMITYJ0eytjyqBg0wjdRlMCpHEtGVosqLe8sNZo7NeZ9tPexhZxWXQNRA6VyE1NwDYd9yPsKBSPrulFcvkgoGOuL98rfE9iizMDYD60G0eTgafnR_99-IH4yoBMIjf4UNVzAcw8revbSE-3bgmxpdjVTJtVXw9LH13BHdB6GN7KJDwH4NRoz_MQJMSBELU18zY2yjmkNLXYI3YArTE3OQ-qBzD7rgPrcJBmoWTswbNVnTl2csFVHdQZOo';
+
+        $jwtToken = new JWTToken($someToken);
+        $this->assertEquals($jwtToken->__toString(), $someToken);
+        $this->assertEquals($jwtToken->getAuthString(), 'Bearer ' . $someToken);
+    }
+}
\ No newline at end of file
diff --git a/tests/unit/Client/TestClientFactory.php b/tests/unit/Client/TestClientFactory.php
new file mode 100644
index 0000000..83dc5a9
--- /dev/null
+++ b/tests/unit/Client/TestClientFactory.php
@@ -0,0 +1,67 @@
+<?php
+
+use WPDesk\ApiClient\Client\ClientFactory;
+
+class TestClientFactory extends \PHPUnit\Framework\TestCase
+{
+
+    /**
+     * Prepare client options.
+     *
+     * @param bool $isCachedClient
+     *
+     * @return \Mockery\MockInterface|\WPDesk\ApiClient\Client\ApiClientOptions
+     */
+    private function prepareOptions($isCachedClient = false)
+    {
+        $options = Mockery::mock(\WPDesk\ApiClient\Client\ApiClientOptions::class);
+        $options->shouldReceive('getHttpClientClass')
+                ->withAnyArgs()
+                ->andReturn(\WPDesk\HttpClient\Curl\CurlClient::class);
+
+        $options->shouldReceive('getSerializerClass')
+                ->withAnyArgs()
+                ->andReturn(\WPDesk\ApiClient\Serializer\JsonSerializer::class);
+
+        $options->shouldReceive('getLogger')
+                ->withAnyArgs()
+                ->andReturn(new \Psr\Log\NullLogger());
+
+        $options->shouldReceive('getApiUrl')
+                ->withAnyArgs()
+                ->andReturn('https://app.flexibleshipping.com/api/v1');
+
+        $options->shouldReceive('getDefaultRequestHeaders')
+                ->withAnyArgs()
+                ->andReturn(array());
+
+        $options->shouldReceive('isCachedClient')
+                ->withAnyArgs()
+                ->andReturn($isCachedClient);
+
+        return $options;
+    }
+
+    /**
+     * Test createClient method.
+     */
+    public function testCreateClient()
+    {
+
+        $clientFactory = new ClientFactory();
+        $client = $clientFactory->createClient($this->prepareOptions());
+        $this->assertInstanceOf(\WPDesk\ApiClient\Client\ClientImplementation::class, $client);
+    }
+
+    /**
+     * Test createClient method.
+     */
+    public function testCreateClientCached()
+    {
+
+        $clientFactory = new ClientFactory();
+        $client = $clientFactory->createClient($this->prepareOptions(true));
+        $this->assertInstanceOf(\WPDesk\ApiClient\Client\CachedClient::class, $client);
+    }
+
+}
\ No newline at end of file
diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php
new file mode 100644
index 0000000..76b8109
--- /dev/null
+++ b/tests/unit/bootstrap.php
@@ -0,0 +1,9 @@
+<?php
+/**
+ * PHPUnit bootstrap file
+ */
+
+require_once __DIR__ . '/../../vendor/autoload.php';
+
+WP_Mock::setUsePatchwork( true );
+WP_Mock::bootstrap();
-- 
GitLab