diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c845f8e..6c8e9e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/kbsali/php-redmine-api/compare/v2.4.0...v2.x) +### Changed + +- The last response is saved in `Redmine\Api\AbstractApi` to prevent race conditions with `Redmine\Client\Client` implementations. + ## [v2.4.0](https://github.com/kbsali/php-redmine-api/compare/v2.3.0...v2.4.0) - 2024-01-04 ### Added diff --git a/composer.json b/composer.json index 59982d2f..f9a45932 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,11 @@ "codestyle": "php-cs-fixer fix", "coverage": "phpunit --coverage-html=\".phpunit.cache/code-coverage\"", "phpstan": "phpstan analyze --memory-limit 512M --configuration .phpstan.neon", - "test": "phpunit" + "phpunit": "phpunit", + "test": [ + "@phpstan", + "@phpunit", + "@codestyle --dry-run --diff" + ] } } diff --git a/src/Redmine/Api/AbstractApi.php b/src/Redmine/Api/AbstractApi.php index 281a90f7..3b2851c2 100644 --- a/src/Redmine/Api/AbstractApi.php +++ b/src/Redmine/Api/AbstractApi.php @@ -2,10 +2,14 @@ namespace Redmine\Api; +use Closure; +use InvalidArgumentException; use Redmine\Api; use Redmine\Client\Client; use Redmine\Exception; use Redmine\Exception\SerializerException; +use Redmine\Http\HttpClient; +use Redmine\Http\Response; use Redmine\Serializer\JsonSerializer; use Redmine\Serializer\PathSerializer; use Redmine\Serializer\XmlSerializer; @@ -23,9 +27,52 @@ abstract class AbstractApi implements Api */ protected $client; - public function __construct(Client $client) + /** + * @var HttpClient + */ + private $httpClient; + + /** + * @var Response + */ + private $lastResponse; + + /** + * @param Client|HttpClient $client + */ + public function __construct($client) { - $this->client = $client; + if (! is_object($client) || (! $client instanceof Client && ! $client instanceof HttpClient)) { + throw new InvalidArgumentException(sprintf( + '%s(): Argument #1 ($client) must be of type %s or %s, `%s` given', + __METHOD__, + Client::class, + HttpClient::class, + (is_object($client)) ? get_class($client) : gettype($client) + )); + } + + if ($client instanceof Client) { + $this->client = $client; + } + + $httpClient = $client; + + if (! $httpClient instanceof HttpClient) { + $httpClient = $this->handleClient($client); + } + + $this->httpClient = $httpClient; + } + + final protected function getHttpClient(): HttpClient + { + return $this->httpClient; + } + + final protected function getLastResponse(): Response + { + return $this->lastResponse !== null ? $this->lastResponse : $this->createResponse(0, '', ''); } /** @@ -40,7 +87,13 @@ public function lastCallFailed() { @trigger_error('`' . __METHOD__ . '()` is deprecated since v2.1.0, use \Redmine\Client\Client::getLastResponseStatusCode() instead.', E_USER_DEPRECATED); - $code = $this->client->getLastResponseStatusCode(); + if ($this->lastResponse !== null) { + $code = $this->lastResponse->getStatusCode(); + } elseif ($this->client !== null) { + $code = $this->client->getLastResponseStatusCode(); + } else { + $code = 0; + } return 200 !== $code && 201 !== $code; } @@ -55,10 +108,10 @@ public function lastCallFailed() */ protected function get($path, $decodeIfJson = true) { - $this->client->requestGet(strval($path)); + $this->lastResponse = $this->getHttpClient()->request('GET', strval($path)); - $body = $this->client->getLastResponseBody(); - $contentType = $this->client->getLastResponseContentType(); + $body = $this->lastResponse->getBody(); + $contentType = $this->lastResponse->getContentType(); // if response is XML, return a SimpleXMLElement object if ('' !== $body && 0 === strpos($contentType, 'application/xml')) { @@ -82,16 +135,17 @@ protected function get($path, $decodeIfJson = true) * @param string $path * @param string $data * - * @return string|false + * @return string|SimpleXMLElement|false */ protected function post($path, $data) { - $this->client->requestPost($path, $data); + $this->lastResponse = $this->getHttpClient()->request('POST', strval($path), $data); - $body = $this->client->getLastResponseBody(); + $body = $this->lastResponse->getBody(); + $contentType = $this->lastResponse->getContentType(); // if response is XML, return a SimpleXMLElement object - if ('' !== $body && 0 === strpos($this->client->getLastResponseContentType(), 'application/xml')) { + if ('' !== $body && 0 === strpos($contentType, 'application/xml')) { return new SimpleXMLElement($body); } @@ -104,16 +158,17 @@ protected function post($path, $data) * @param string $path * @param string $data * - * @return string|false + * @return string|SimpleXMLElement */ protected function put($path, $data) { - $this->client->requestPut($path, $data); + $this->lastResponse = $this->getHttpClient()->request('PUT', strval($path), $data); - $body = $this->client->getLastResponseBody(); + $body = $this->lastResponse->getBody(); + $contentType = $this->lastResponse->getContentType(); // if response is XML, return a SimpleXMLElement object - if ('' !== $body && 0 === strpos($this->client->getLastResponseContentType(), 'application/xml')) { + if ('' !== $body && 0 === strpos($contentType, 'application/xml')) { return new SimpleXMLElement($body); } @@ -125,13 +180,13 @@ protected function put($path, $data) * * @param string $path * - * @return false|SimpleXMLElement|string + * @return string */ protected function delete($path) { - $this->client->requestDelete($path); + $this->lastResponse = $this->getHttpClient()->request('DELETE', strval($path)); - return $this->client->getLastResponseBody(); + return $this->lastResponse->getBody(); } /** @@ -179,7 +234,7 @@ protected function retrieveAll($endpoint, array $params = []) try { $data = $this->retrieveData(strval($endpoint), $params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } @@ -203,9 +258,9 @@ protected function retrieveAll($endpoint, array $params = []) protected function retrieveData(string $endpoint, array $params = []): array { if (empty($params)) { - $this->client->requestGet($endpoint); + $this->lastResponse = $this->getHttpClient()->request('GET', strval($endpoint)); - return $this->getLastResponseBodyAsArray(); + return $this->getResponseAsArray($this->lastResponse); } $params = $this->sanitizeParams( @@ -232,11 +287,12 @@ protected function retrieveData(string $endpoint, array $params = []): array $params['limit'] = $_limit; $params['offset'] = $offset; - $this->client->requestGet( + $this->lastResponse = $this->getHttpClient()->request( + 'GET', PathSerializer::create($endpoint, $params)->getPath() ); - $newDataSet = $this->getLastResponseBodyAsArray(); + $newDataSet = $this->getResponseAsArray($this->lastResponse); $returnData = array_merge_recursive($returnData, $newDataSet); @@ -310,11 +366,10 @@ protected function attachCustomFieldXML(SimpleXMLElement $xml, array $fields) * * @throws SerializerException if response body could not be converted into array */ - private function getLastResponseBodyAsArray(): array + private function getResponseAsArray(Response $response): array { - $body = $this->client->getLastResponseBody(); - - $contentType = $this->client->getLastResponseContentType(); + $body = $response->getBody(); + $contentType = $response->getContentType(); $returnData = null; // parse XML @@ -330,4 +385,70 @@ private function getLastResponseBodyAsArray(): array return $returnData; } + + private function handleClient(Client $client): HttpClient + { + $responseFactory = Closure::fromCallable([$this, 'createResponse']); + + return new class ($client, $responseFactory) implements HttpClient { + private $client; + private $responseFactory; + + public function __construct(Client $client, Closure $responseFactory) + { + $this->client = $client; + $this->responseFactory = $responseFactory; + } + + public function request(string $method, string $path, string $body = ''): Response + { + if ($method === 'POST') { + $this->client->requestPost($path, $body); + } elseif ($method === 'PUT') { + $this->client->requestPut($path, $body); + } elseif ($method === 'DELETE') { + $this->client->requestDelete($path); + } else { + $this->client->requestGet($path); + } + + return ($this->responseFactory)( + $this->client->getLastResponseStatusCode(), + $this->client->getLastResponseContentType(), + $this->client->getLastResponseBody() + ); + } + }; + } + + private function createResponse(int $statusCode, string $contentType, string $body): Response + { + return new class ($statusCode, $contentType, $body) implements Response { + private $statusCode; + private $contentType; + private $body; + + public function __construct(int $statusCode, string $contentType, string $body) + { + $this->statusCode = $statusCode; + $this->contentType = $contentType; + $this->body = $body; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getBody(): string + { + return $this->body; + } + }; + } } diff --git a/src/Redmine/Api/CustomField.php b/src/Redmine/Api/CustomField.php index 525fae43..1b9d10c6 100644 --- a/src/Redmine/Api/CustomField.php +++ b/src/Redmine/Api/CustomField.php @@ -55,7 +55,7 @@ public function all(array $params = []) try { $this->customFields = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Group.php b/src/Redmine/Api/Group.php index 51101862..206dcc62 100644 --- a/src/Redmine/Api/Group.php +++ b/src/Redmine/Api/Group.php @@ -58,7 +58,7 @@ public function all(array $params = []) try { $this->groups = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Issue.php b/src/Redmine/Api/Issue.php index 2bd3a0ae..3d3264cc 100644 --- a/src/Redmine/Api/Issue.php +++ b/src/Redmine/Api/Issue.php @@ -2,12 +2,15 @@ namespace Redmine\Api; +use Redmine\Client\NativeCurlClient; +use Redmine\Client\Psr18Client; use Redmine\Exception; use Redmine\Exception\SerializerException; use Redmine\Exception\UnexpectedResponseException; use Redmine\Serializer\JsonSerializer; use Redmine\Serializer\PathSerializer; use Redmine\Serializer\XmlSerializer; +use SimpleXMLElement; /** * Listing issues, searching, editing and closing your projects issues. @@ -24,6 +27,31 @@ class Issue extends AbstractApi public const PRIO_URGENT = 4; public const PRIO_IMMEDIATE = 5; + /** + * @var IssueCategory + */ + private $issueCategoryApi; + + /** + * @var IssueStatus + */ + private $issueStatusApi; + + /** + * @var Project + */ + private $projectApi; + + /** + * @var Tracker + */ + private $trackerApi; + + /** + * @var User + */ + private $userApi; + /** * List issues. * @@ -82,7 +110,7 @@ public function all(array $params = []) try { return $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } @@ -125,7 +153,7 @@ public function show($id, array $params = []) * * @param array $params the new issue data * - * @return string|false + * @return string|SimpleXMLElement|false */ public function create(array $params = []) { @@ -160,7 +188,7 @@ public function create(array $params = []) * * @param string $id the issue number * - * @return string|false + * @return string|SimpleXMLElement|false */ public function update($id, array $params) { @@ -219,15 +247,14 @@ public function removeWatcher($id, $watcherUserId) * @param int $id * @param string $status * - * @return string|false + * @return string|SimpleXMLElement|false */ public function setIssueStatus($id, $status) { - /** @var IssueStatus */ - $api = $this->client->getApi('issue_status'); + $issueStatusApi = $this->getIssueStatusApi(); return $this->update($id, [ - 'status_id' => $api->getIdByName($status), + 'status_id' => $issueStatusApi->getIdByName($status), ]); } @@ -254,39 +281,44 @@ public function addNoteToIssue($id, $note, $privateNote = false) private function cleanParams(array $params = []) { if (isset($params['project'])) { - /** @var Project */ - $apiProject = $this->client->getApi('project'); - $params['project_id'] = $apiProject->getIdByName($params['project']); + $projectApi = $this->getProjectApi(); + + $params['project_id'] = $projectApi->getIdByName($params['project']); unset($params['project']); - if (isset($params['category'])) { - /** @var IssueCategory */ - $apiIssueCategory = $this->client->getApi('issue_category'); - $params['category_id'] = $apiIssueCategory->getIdByName($params['project_id'], $params['category']); - unset($params['category']); - } } + + if (isset($params['category']) && isset($params['project_id'])) { + $issueCategoryApi = $this->getIssueCategoryApi(); + + $params['category_id'] = $issueCategoryApi->getIdByName($params['project_id'], $params['category']); + unset($params['category']); + } + if (isset($params['status'])) { - /** @var IssueStatus */ - $apiIssueStatus = $this->client->getApi('issue_status'); - $params['status_id'] = $apiIssueStatus->getIdByName($params['status']); + $issueStatusApi = $this->getIssueStatusApi(); + + $params['status_id'] = $issueStatusApi->getIdByName($params['status']); unset($params['status']); } + if (isset($params['tracker'])) { - /** @var Tracker */ - $apiTracker = $this->client->getApi('tracker'); - $params['tracker_id'] = $apiTracker->getIdByName($params['tracker']); + $trackerApi = $this->getTrackerApi(); + + $params['tracker_id'] = $trackerApi->getIdByName($params['tracker']); unset($params['tracker']); } + if (isset($params['assigned_to'])) { - /** @var User */ - $apiUser = $this->client->getApi('user'); - $params['assigned_to_id'] = $apiUser->getIdByUsername($params['assigned_to']); + $userApi = $this->getUserApi(); + + $params['assigned_to_id'] = $userApi->getIdByUsername($params['assigned_to']); unset($params['assigned_to']); } + if (isset($params['author'])) { - /** @var User */ - $apiUser = $this->client->getApi('user'); - $params['author_id'] = $apiUser->getIdByUsername($params['author']); + $userApi = $this->getUserApi(); + + $params['author_id'] = $userApi->getIdByUsername($params['author']); unset($params['author']); } @@ -345,4 +377,99 @@ public function remove($id) { return $this->delete('/issues/' . $id . '.xml'); } + + /** + * @return IssueCategory + */ + private function getIssueCategoryApi() + { + if ($this->issueCategoryApi === null) { + if ($this->client !== null && ! $this->client instanceof NativeCurlClient && ! $this->client instanceof Psr18Client) { + /** @var IssueCategory */ + $issueCategoryApi = $this->client->getApi('issue_category'); + } else { + $issueCategoryApi = new IssueCategory($this->getHttpClient()); + } + + $this->issueCategoryApi = $issueCategoryApi; + } + + return $this->issueCategoryApi; + } + + /** + * @return IssueStatus + */ + private function getIssueStatusApi() + { + if ($this->issueStatusApi === null) { + if ($this->client !== null && ! $this->client instanceof NativeCurlClient && ! $this->client instanceof Psr18Client) { + /** @var IssueStatus */ + $issueStatusApi = $this->client->getApi('issue_status'); + } else { + $issueStatusApi = new IssueStatus($this->getHttpClient()); + } + + $this->issueStatusApi = $issueStatusApi; + } + + return $this->issueStatusApi; + } + + /** + * @return Project + */ + private function getProjectApi() + { + if ($this->projectApi === null) { + if ($this->client !== null && ! $this->client instanceof NativeCurlClient && ! $this->client instanceof Psr18Client) { + /** @var Project */ + $projectApi = $this->client->getApi('project'); + } else { + $projectApi = new Project($this->getHttpClient()); + } + + $this->projectApi = $projectApi; + } + + return $this->projectApi; + } + + /** + * @return Tracker + */ + private function getTrackerApi() + { + if ($this->trackerApi === null) { + if ($this->client !== null && ! $this->client instanceof NativeCurlClient && ! $this->client instanceof Psr18Client) { + /** @var Tracker */ + $trackerApi = $this->client->getApi('tracker'); + } else { + $trackerApi = new Tracker($this->getHttpClient()); + } + + $this->trackerApi = $trackerApi; + } + + return $this->trackerApi; + } + + /** + * @return User + */ + private function getUserApi() + { + if ($this->userApi === null) { + if ($this->client !== null && ! $this->client instanceof NativeCurlClient && ! $this->client instanceof Psr18Client) { + /** @var User */ + $userApi = $this->client->getApi('user'); + } else { + $userApi = new User($this->getHttpClient()); + } + + $this->userApi = $userApi; + } + + return $this->userApi; + } } diff --git a/src/Redmine/Api/IssueCategory.php b/src/Redmine/Api/IssueCategory.php index bac5d553..d42900f2 100644 --- a/src/Redmine/Api/IssueCategory.php +++ b/src/Redmine/Api/IssueCategory.php @@ -69,7 +69,7 @@ public function all($project, array $params = []) try { return $this->listByProject(strval($project), $params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/IssuePriority.php b/src/Redmine/Api/IssuePriority.php index 917a9579..e3e777b3 100644 --- a/src/Redmine/Api/IssuePriority.php +++ b/src/Redmine/Api/IssuePriority.php @@ -55,7 +55,7 @@ public function all(array $params = []) try { $this->issuePriorities = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/IssueRelation.php b/src/Redmine/Api/IssueRelation.php index 26b7f19b..113674e4 100644 --- a/src/Redmine/Api/IssueRelation.php +++ b/src/Redmine/Api/IssueRelation.php @@ -58,7 +58,7 @@ public function all($issueId, array $params = []) try { $this->relations = $this->listByIssueId($issueId, $params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/IssueStatus.php b/src/Redmine/Api/IssueStatus.php index eec0e127..fa6fd577 100644 --- a/src/Redmine/Api/IssueStatus.php +++ b/src/Redmine/Api/IssueStatus.php @@ -55,7 +55,7 @@ public function all(array $params = []) try { $this->issueStatuses = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Membership.php b/src/Redmine/Api/Membership.php index 62d8f90f..7650ea22 100644 --- a/src/Redmine/Api/Membership.php +++ b/src/Redmine/Api/Membership.php @@ -68,7 +68,7 @@ public function all($project, array $params = []) try { $this->memberships = $this->listByProject(strval($project), $params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/News.php b/src/Redmine/Api/News.php index 6999ca3e..e6366e17 100644 --- a/src/Redmine/Api/News.php +++ b/src/Redmine/Api/News.php @@ -88,7 +88,7 @@ public function all($project = null, array $params = []) $this->news = $this->listByProject(strval($project), $params); } } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Project.php b/src/Redmine/Api/Project.php index 259f1ae8..123086e5 100755 --- a/src/Redmine/Api/Project.php +++ b/src/Redmine/Api/Project.php @@ -58,7 +58,7 @@ public function all(array $params = []) try { $this->projects = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Query.php b/src/Redmine/Api/Query.php index da629b87..475a551f 100644 --- a/src/Redmine/Api/Query.php +++ b/src/Redmine/Api/Query.php @@ -55,7 +55,7 @@ public function all(array $params = []) try { $this->query = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Role.php b/src/Redmine/Api/Role.php index b01b5c6d..67832c89 100644 --- a/src/Redmine/Api/Role.php +++ b/src/Redmine/Api/Role.php @@ -55,7 +55,7 @@ public function all(array $params = []) try { $this->roles = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Search.php b/src/Redmine/Api/Search.php index d9fc1fbb..f286b9c6 100644 --- a/src/Redmine/Api/Search.php +++ b/src/Redmine/Api/Search.php @@ -55,7 +55,7 @@ public function search($query, array $params = []) try { $this->results = $this->listByQuery($query, $params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/TimeEntry.php b/src/Redmine/Api/TimeEntry.php index 74c49084..092f68e3 100644 --- a/src/Redmine/Api/TimeEntry.php +++ b/src/Redmine/Api/TimeEntry.php @@ -57,7 +57,7 @@ public function all(array $params = []) try { $this->timeEntries = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/TimeEntryActivity.php b/src/Redmine/Api/TimeEntryActivity.php index 50c4fa13..871fe789 100644 --- a/src/Redmine/Api/TimeEntryActivity.php +++ b/src/Redmine/Api/TimeEntryActivity.php @@ -51,7 +51,7 @@ public function all(array $params = []) try { $this->timeEntryActivities = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Tracker.php b/src/Redmine/Api/Tracker.php index 500d7ded..85f3507c 100644 --- a/src/Redmine/Api/Tracker.php +++ b/src/Redmine/Api/Tracker.php @@ -55,7 +55,7 @@ public function all(array $params = []) try { $this->trackers = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/User.php b/src/Redmine/Api/User.php index 536c8b67..ca719a70 100644 --- a/src/Redmine/Api/User.php +++ b/src/Redmine/Api/User.php @@ -58,7 +58,7 @@ public function all(array $params = []) try { $this->users = $this->list($params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Version.php b/src/Redmine/Api/Version.php index a77f6f95..3252c985 100644 --- a/src/Redmine/Api/Version.php +++ b/src/Redmine/Api/Version.php @@ -67,7 +67,7 @@ public function all($project, array $params = []) try { $this->versions = $this->listByProject(strval($project), $params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Api/Wiki.php b/src/Redmine/Api/Wiki.php index d5c4ed35..7a066978 100644 --- a/src/Redmine/Api/Wiki.php +++ b/src/Redmine/Api/Wiki.php @@ -67,7 +67,7 @@ public function all($project, array $params = []) try { $this->wikiPages = $this->listByProject(strval($project), $params); } catch (Exception $e) { - if ($this->client->getLastResponseBody() === '') { + if ($this->getLastResponse()->getBody() === '') { return false; } diff --git a/src/Redmine/Exception/ClientException.php b/src/Redmine/Exception/ClientException.php index 426b227d..c81bc3f1 100644 --- a/src/Redmine/Exception/ClientException.php +++ b/src/Redmine/Exception/ClientException.php @@ -5,4 +5,9 @@ use Exception; use Redmine\Exception as RedmineException; +/** + * Client exception. + * + * Will be thrown if anything goes wrong on creating or sending a HTTP request + */ class ClientException extends Exception implements RedmineException {} diff --git a/src/Redmine/Http/HttpClient.php b/src/Redmine/Http/HttpClient.php new file mode 100644 index 00000000..0cc74fc4 --- /dev/null +++ b/src/Redmine/Http/HttpClient.php @@ -0,0 +1,28 @@ +createMock(Response::class); + $response->expects($this->any())->method('getStatusCode')->willReturn(200); + $response->expects($this->any())->method('getContentType')->willReturn('application/xml'); + $response->expects($this->any())->method('getBody')->willReturn(''); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(1))->method('request')->with('DELETE', 'path.xml', '')->willReturn($response); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'delete'); + $method->setAccessible(true); + + // Perform the tests + $return = $method->invoke($api, 'path.xml'); + + $this->assertSame('', $return); + } + + /** + * @dataProvider getXmlDecodingFromDeleteMethodData + */ + public function testXmlDecodingFromDeleteMethod($response, $expected) + { + $client = $this->createMock(Client::class); + $client->method('getLastResponseBody')->willReturn($response); + $client->method('getLastResponseContentType')->willReturn('application/xml'); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'delete'); + $method->setAccessible(true); + + // Perform the tests + $return = $method->invoke($api, 'path.xml'); + + $this->assertSame($expected, $return); + } + + public static function getXmlDecodingFromDeleteMethodData(): array + { + return [ + 'no decode by default' => ['', ''], + ]; + } +} diff --git a/tests/Unit/Api/AbstractApi/GetTest.php b/tests/Unit/Api/AbstractApi/GetTest.php new file mode 100644 index 00000000..29750b59 --- /dev/null +++ b/tests/Unit/Api/AbstractApi/GetTest.php @@ -0,0 +1,105 @@ +createMock(Response::class); + $response->expects($this->any())->method('getStatusCode')->willReturn(200); + $response->expects($this->any())->method('getContentType')->willReturn('application/json'); + $response->expects($this->any())->method('getBody')->willReturn('{"foo_bar": 12345}'); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(1))->method('request')->with('GET', 'path.json')->willReturn($response); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'get'); + $method->setAccessible(true); + + // Perform the tests + $this->assertSame( + ['foo_bar' => 12345], + $method->invoke($api, 'path.json') + ); + } + + /** + * @dataProvider getJsonDecodingFromGetMethodData + */ + public function testJsonDecodingFromGetMethod($response, $decode, $expected) + { + $client = $this->createMock(Client::class); + $client->method('getLastResponseBody')->willReturn($response); + $client->method('getLastResponseContentType')->willReturn('application/json'); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'get'); + $method->setAccessible(true); + + // Perform the tests + if (is_bool($decode)) { + $this->assertSame($expected, $method->invoke($api, 'path', $decode)); + } else { + $this->assertSame($expected, $method->invoke($api, 'path')); + } + } + + public static function getJsonDecodingFromGetMethodData(): array + { + return [ + 'test decode by default' => ['{"foo_bar": 12345}', null, ['foo_bar' => 12345]], + 'test decode by default, JSON decode: false' => ['{"foo_bar": 12345}', false, '{"foo_bar": 12345}'], + 'test decode by default, JSON decode: true' => ['{"foo_bar": 12345}', true, ['foo_bar' => 12345]], + 'Empty body, JSON decode: false' => ['', false, false], + 'Empty body, JSON decode: true' => ['', true, false], + 'test invalid JSON' => ['{"foo_bar":', true, 'Error decoding body as JSON: Syntax error'], + ]; + } + + /** + * @dataProvider getXmlDecodingFromGetMethodData + */ + public function testXmlDecodingFromGetMethod($response, $decode, $expected) + { + $client = $this->createMock(Client::class); + $client->method('getLastResponseBody')->willReturn($response); + $client->method('getLastResponseContentType')->willReturn('application/xml'); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'get'); + $method->setAccessible(true); + + // Perform the tests + $return = $method->invoke($api, 'path', $decode); + + $this->assertInstanceOf(SimpleXMLElement::class, $return); + $this->assertXmlStringEqualsXmlString($expected, $return->asXML()); + } + + public static function getXmlDecodingFromGetMethodData(): array + { + return [ + 'decode by default' => ['', null, ''], // test decode by default + 'decode true' => ['', true, ''], + 'decode false' => ['', false, ''], // test that xml decoding will be always happen + ]; + } +} diff --git a/tests/Unit/Api/AbstractApi/PostTest.php b/tests/Unit/Api/AbstractApi/PostTest.php new file mode 100644 index 00000000..7f08ea62 --- /dev/null +++ b/tests/Unit/Api/AbstractApi/PostTest.php @@ -0,0 +1,69 @@ +createMock(Response::class); + $response->expects($this->any())->method('getStatusCode')->willReturn(200); + $response->expects($this->any())->method('getContentType')->willReturn('application/xml'); + $response->expects($this->any())->method('getBody')->willReturn(''); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(1))->method('request')->with('POST', 'path.xml', '')->willReturn($response); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'post'); + $method->setAccessible(true); + + // Perform the tests + $return = $method->invoke($api, 'path.xml', ''); + + $this->assertInstanceOf(SimpleXMLElement::class, $return); + $this->assertXmlStringEqualsXmlString('', $return->asXML()); + } + + /** + * @dataProvider getXmlDecodingFromPostMethodData + */ + public function testXmlDecodingFromPostMethod($response, $expected) + { + $client = $this->createMock(Client::class); + $client->method('getLastResponseBody')->willReturn($response); + $client->method('getLastResponseContentType')->willReturn('application/xml'); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'post'); + $method->setAccessible(true); + + // Perform the tests + $return = $method->invoke($api, 'path.xml', ''); + + $this->assertInstanceOf(SimpleXMLElement::class, $return); + $this->assertXmlStringEqualsXmlString($expected, $return->asXML()); + } + + public static function getXmlDecodingFromPostMethodData(): array + { + return [ + 'decode by default' => ['', ''], // test decode by default + ]; + } +} diff --git a/tests/Unit/Api/AbstractApi/PutTest.php b/tests/Unit/Api/AbstractApi/PutTest.php new file mode 100644 index 00000000..41071ecb --- /dev/null +++ b/tests/Unit/Api/AbstractApi/PutTest.php @@ -0,0 +1,69 @@ +createMock(Response::class); + $response->expects($this->any())->method('getStatusCode')->willReturn(200); + $response->expects($this->any())->method('getContentType')->willReturn('application/xml'); + $response->expects($this->any())->method('getBody')->willReturn(''); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(1))->method('request')->with('PUT', 'path.xml', '')->willReturn($response); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'put'); + $method->setAccessible(true); + + // Perform the tests + $return = $method->invoke($api, 'path.xml', ''); + + $this->assertInstanceOf(SimpleXMLElement::class, $return); + $this->assertXmlStringEqualsXmlString('', $return->asXML()); + } + + /** + * @dataProvider getXmlDecodingFromPutMethodData + */ + public function testXmlDecodingFromPutMethod($response, $expected) + { + $client = $this->createMock(Client::class); + $client->method('getLastResponseBody')->willReturn($response); + $client->method('getLastResponseContentType')->willReturn('application/xml'); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'put'); + $method->setAccessible(true); + + // Perform the tests + $return = $method->invoke($api, 'path.xml', ''); + + $this->assertInstanceOf(SimpleXMLElement::class, $return); + $this->assertXmlStringEqualsXmlString($expected, $return->asXML()); + } + + public static function getXmlDecodingFromPutMethodData(): array + { + return [ + 'decode by default' => ['', ''], // test decode by default + ]; + } +} diff --git a/tests/Unit/Api/AbstractApiTest.php b/tests/Unit/Api/AbstractApiTest.php index 42e6eb21..4cb0c355 100644 --- a/tests/Unit/Api/AbstractApiTest.php +++ b/tests/Unit/Api/AbstractApiTest.php @@ -2,10 +2,13 @@ namespace Redmine\Tests\Unit\Api; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Redmine\Api\AbstractApi; use Redmine\Client\Client; use Redmine\Exception\SerializerException; +use Redmine\Http\HttpClient; +use Redmine\Http\Response; use ReflectionMethod; use SimpleXMLElement; @@ -14,6 +17,53 @@ */ class AbstractApiTest extends TestCase { + public function testCreateWithHttpClientWorks() + { + $client = $this->createMock(HttpClient::class); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'getHttpClient'); + $method->setAccessible(true); + + $this->assertSame($client, $method->invoke($api)); + } + + public function testCreateWitClientWorks() + { + $client = $this->createMock(Client::class); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'getHttpClient'); + $method->setAccessible(true); + + $this->assertInstanceOf(HttpClient::class, $method->invoke($api)); + } + + public function testCreateWithoutClitentOrHttpClientThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Redmine\Api\AbstractApi::__construct(): Argument #1 ($client) must be of type Redmine\Client\Client or Redmine\Http\HttpClient, `stdClass` given'); + + new class (new \stdClass()) extends AbstractApi {}; + } + + /** + * @covers ::getLastResponse + */ + public function testGetLastResponseWithHttpClientWorks() + { + $client = $this->createMock(HttpClient::class); + + $api = new class ($client) extends AbstractApi {}; + + $method = new ReflectionMethod($api, 'getLastResponse'); + $method->setAccessible(true); + + $this->assertInstanceOf(Response::class, $method->invoke($api)); + } + /** * @test * @dataProvider getIsNotNullReturnsCorrectBooleanData @@ -53,11 +103,51 @@ public static function getIsNotNullReturnsCorrectBooleanData(): array } /** - * @covers \Redmine\Api\AbstractApi + * @covers ::lastCallFailed + */ + public function testLastCallFailedPreventsRaceCondition() + { + $response1 = $this->createMock(Response::class); + $response1->method('getStatusCode')->willReturn(200); + + $response2 = $this->createMock(Response::class); + $response2->method('getStatusCode')->willReturn(500); + + $client = $this->createMock(HttpClient::class); + $client->method('request')->willReturnMap([ + ['GET', '200.json', '', $response1], + ['GET', '500.json', '', $response2], + ]); + + $api1 = new class ($client) extends AbstractApi { + public function __construct($client) + { + parent::__construct($client); + parent::get('200.json', false); + } + }; + + $api2 = new class ($client) extends AbstractApi { + public function __construct($client) + { + parent::__construct($client); + parent::get('500.json', false); + } + }; + + $api3 = new class ($client) extends AbstractApi {}; + + $this->assertSame(false, $api1->lastCallFailed()); + $this->assertSame(true, $api2->lastCallFailed()); + $this->assertSame(true, $api3->lastCallFailed()); + } + + /** + * @covers ::lastCallFailed * @test * @dataProvider getLastCallFailedData */ - public function testLastCallFailedReturnsCorrectBoolean($statusCode, $expectedBoolean) + public function testLastCallFailedWithClientReturnsCorrectBoolean($statusCode, $expectedBoolean) { $client = $this->createMock(Client::class); $client->method('getLastResponseStatusCode')->willReturn($statusCode); @@ -67,9 +157,34 @@ public function testLastCallFailedReturnsCorrectBoolean($statusCode, $expectedBo $this->assertSame($expectedBoolean, $api->lastCallFailed()); } + /** + * @covers ::lastCallFailed + * @test + * @dataProvider getLastCallFailedData + */ + public function testLastCallFailedWithHttpClientReturnsCorrectBoolean($statusCode, $expectedBoolean) + { + $response = $this->createMock(Response::class); + $response->method('getStatusCode')->willReturn($statusCode); + + $client = $this->createMock(HttpClient::class); + $client->method('request')->willReturn($response); + + $api = new class ($client) extends AbstractApi { + public function __construct($client) + { + parent::__construct($client); + $this->get('', false); + } + }; + + $this->assertSame($expectedBoolean, $api->lastCallFailed()); + } + public static function getLastCallFailedData(): array { return [ + [0, true], [100, true], [101, true], [102, true], @@ -138,89 +253,7 @@ public static function getLastCallFailedData(): array } /** - * @covers \Redmine\Api\AbstractApi - * @test - * @dataProvider getJsonDecodingFromGetMethodData - */ - public function testJsonDecodingFromGetMethod($response, $decode, $expected) - { - $client = $this->createMock(Client::class); - $client->method('getLastResponseBody')->willReturn($response); - $client->method('getLastResponseContentType')->willReturn('application/json'); - - $api = new class ($client) extends AbstractApi {}; - - $method = new ReflectionMethod($api, 'get'); - $method->setAccessible(true); - - // Perform the tests - if (is_bool($decode)) { - $this->assertSame($expected, $method->invoke($api, 'path', $decode)); - } else { - $this->assertSame($expected, $method->invoke($api, 'path')); - } - } - - public static function getJsonDecodingFromGetMethodData(): array - { - return [ - 'test decode by default' => ['{"foo_bar": 12345}', null, ['foo_bar' => 12345]], - 'test decode by default, JSON decode: false' => ['{"foo_bar": 12345}', false, '{"foo_bar": 12345}'], - 'test decode by default, JSON decode: true' => ['{"foo_bar": 12345}', true, ['foo_bar' => 12345]], - 'Empty body, JSON decode: false' => ['', false, false], - 'Empty body, JSON decode: true' => ['', true, false], - 'test invalid JSON' => ['{"foo_bar":', true, 'Error decoding body as JSON: Syntax error'], - ]; - } - - /** - * @covers \Redmine\Api\AbstractApi - * @test - * @dataProvider getXmlDecodingFromGetMethodData - */ - public function testXmlDecodingFromRequestMethods($methodName, $response, $decode, $expected) - { - $client = $this->createMock(Client::class); - $client->method('getLastResponseBody')->willReturn($response); - $client->method('getLastResponseContentType')->willReturn('application/xml'); - - $api = new class ($client) extends AbstractApi {}; - - $method = new ReflectionMethod($api, $methodName); - $method->setAccessible(true); - - // Perform the tests - if ('get' === $methodName) { - $return = $method->invoke($api, 'path', $decode); - - $this->assertInstanceOf(SimpleXMLElement::class, $return); - $this->assertXmlStringEqualsXmlString($expected, $return->asXML()); - } elseif ('delete' === $methodName) { - $return = $method->invoke($api, 'path'); - - $this->assertSame($expected, $return); - } else { - $return = $method->invoke($api, 'path', ''); - - $this->assertInstanceOf(SimpleXMLElement::class, $return); - $this->assertXmlStringEqualsXmlString($expected, $return->asXML()); - } - } - - public static function getXmlDecodingFromGetMethodData(): array - { - return [ - ['get', '', null, ''], // test decode by default - ['get', '', true, ''], - ['get', '', false, ''], // test that xml decoding will be always happen - ['post', '', null, ''], - ['put', '', null, ''], - ['delete', '', null, ''], - ]; - } - - /** - * @covers \Redmine\Api\AbstractApi::retrieveData + * @covers ::retrieveData * * @dataProvider retrieveDataData */ @@ -247,7 +280,7 @@ public static function retrieveDataData(): array } /** - * @covers \Redmine\Api\AbstractApi::retrieveData + * @covers ::retrieveData * * @dataProvider getRetrieveDataToExceptionData */ @@ -277,7 +310,7 @@ public static function getRetrieveDataToExceptionData(): array } /** - * @covers \Redmine\Api\AbstractApi::retrieveAll + * @covers ::retrieveAll * * @dataProvider getRetrieveAllData */ @@ -306,7 +339,7 @@ public static function getRetrieveAllData(): array } /** - * @covers \Redmine\Api\AbstractApi::attachCustomFieldXML + * @covers ::attachCustomFieldXML */ public function testDeprecatedAttachCustomFieldXML() { diff --git a/tests/Unit/Api/AttachmentTest.php b/tests/Unit/Api/AttachmentTest.php index 5bd3df46..dff94bbe 100644 --- a/tests/Unit/Api/AttachmentTest.php +++ b/tests/Unit/Api/AttachmentTest.php @@ -61,7 +61,6 @@ public static function responseCodeProvider(): array /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -90,7 +89,6 @@ public function testShowReturnsClientGetResponse() /** * Test download(). * - * @covers ::get * @covers ::download * @test */ diff --git a/tests/Unit/Api/CustomFieldTest.php b/tests/Unit/Api/CustomFieldTest.php index dba2bd2d..030a4915 100644 --- a/tests/Unit/Api/CustomFieldTest.php +++ b/tests/Unit/Api/CustomFieldTest.php @@ -44,9 +44,6 @@ function ($errno, $errstr): bool { * Test all(). * * @covers ::all - * @covers ::get - * @covers ::retrieveAll - * @covers ::isNotNull * @dataProvider getAllData * @test */ @@ -85,9 +82,6 @@ public static function getAllData(): array * Test all(). * * @covers ::all - * @covers ::get - * @covers ::retrieveAll - * @covers ::isNotNull * @test */ public function testAllReturnsClientGetResponseWithParameters() @@ -123,9 +117,6 @@ public function testAllReturnsClientGetResponseWithParameters() * Test all(). * * @covers ::all - * @covers ::get - * @covers ::retrieveAll - * @covers ::isNotNull * @test */ public function testAllReturnsClientGetResponseWithHighLimit() @@ -164,9 +155,6 @@ public function testAllReturnsClientGetResponseWithHighLimit() * Test all(). * * @covers ::all - * @covers ::get - * @covers ::retrieveAll - * @covers ::isNotNull * @test */ public function testAllCallsEndpointUntilOffsetIsHigherThanTotalCount() diff --git a/tests/Unit/Api/GroupTest.php b/tests/Unit/Api/GroupTest.php index d6081c6d..fa5fc56a 100644 --- a/tests/Unit/Api/GroupTest.php +++ b/tests/Unit/Api/GroupTest.php @@ -233,7 +233,6 @@ public function testListingCallsGetEveryTimeWithForceUpdate() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -266,7 +265,6 @@ public function testShowReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -332,7 +330,7 @@ public function testRemoveCallsDelete() $client->expects($this->exactly(1)) ->method('getLastResponseBody') ->willReturn($response); - $client->expects($this->exactly(0)) + $client->expects($this->exactly(1)) ->method('getLastResponseContentType') ->willReturn('application/json'); diff --git a/tests/Unit/Api/IssueCategoryTest.php b/tests/Unit/Api/IssueCategoryTest.php index d9cba882..43dc5ae2 100644 --- a/tests/Unit/Api/IssueCategoryTest.php +++ b/tests/Unit/Api/IssueCategoryTest.php @@ -237,7 +237,6 @@ public function testListingCallsGetEveryTimeWithForceUpdate() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ diff --git a/tests/Unit/Api/IssueRelationTest.php b/tests/Unit/Api/IssueRelationTest.php index a75e3708..439722fb 100644 --- a/tests/Unit/Api/IssueRelationTest.php +++ b/tests/Unit/Api/IssueRelationTest.php @@ -119,7 +119,6 @@ public function testAllReturnsClientGetResponseWithParametersAndProject() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -154,7 +153,6 @@ public function testShowReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -213,7 +211,7 @@ public function testRemoveCallsDelete() $client->expects($this->exactly(1)) ->method('getLastResponseBody') ->willReturn($response); - $client->expects($this->exactly(0)) + $client->expects($this->exactly(1)) ->method('getLastResponseContentType') ->willReturn('application/json'); diff --git a/tests/Unit/Api/IssueTest.php b/tests/Unit/Api/IssueTest.php index 50390650..582e8c38 100644 --- a/tests/Unit/Api/IssueTest.php +++ b/tests/Unit/Api/IssueTest.php @@ -5,7 +5,10 @@ use PHPUnit\Framework\TestCase; use Redmine\Api\Issue; use Redmine\Client\Client; +use Redmine\Http\HttpClient; +use Redmine\Http\Response; use Redmine\Tests\Fixtures\MockClient; +use SimpleXMLElement; /** * @coversDefaultClass \Redmine\Api\Issue @@ -142,7 +145,6 @@ public function testAllReturnsClientGetResponseWithParameters() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -175,7 +177,6 @@ public function testShowReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -433,9 +434,14 @@ public function testCreateCallsPost() * * @covers ::create * @covers ::cleanParams + * @covers ::getIssueCategoryApi + * @covers ::getIssueStatusApi + * @covers ::getProjectApi + * @covers ::getTrackerApi + * @covers ::getUserApi * @test */ - public function testCreateCleansParameters() + public function testCreateWithClientCleansParameters() { // Test values $response = 'API Response'; @@ -463,7 +469,7 @@ public function testCreateCleansParameters() ->willReturn('cleanedValue'); $client = $this->createMock(Client::class); - $client->expects($this->exactly(6)) + $client->expects($this->exactly(5)) ->method('getApi') ->willReturnMap( [ @@ -502,6 +508,276 @@ public function testCreateCleansParameters() $this->assertSame($response, $api->create($parameters)); } + /** + * @covers ::create + * @covers ::cleanParams + * @covers ::getIssueStatusApi + * @test + */ + public function testCreateWithHttpClientRetrievesIssueStatusId() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function (string $method, string $path, string $body = '') { + if ($method === 'GET') { + $this->assertSame('/issue_statuses.json', $path); + $this->assertSame('', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/json', + 'getBody' => '{"issue_statuses":[{"name":"Status Name","id":123}]}', + ] + ); + } + + if ($method === 'POST') { + $this->assertSame('/issues.xml', $path); + $this->assertXmlStringEqualsXmlString('123', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/xml', + 'getBody' => '', + ] + ); + } + + throw new \Exception(); + }); + + // Create the object under test + $api = new Issue($client); + + $xmlElement = $api->create(['status' => 'Status Name']); + + // Perform the tests + $this->assertInstanceOf(SimpleXMLElement::class, $xmlElement); + $this->assertXmlStringEqualsXmlString( + '', + $xmlElement->asXml(), + ); + } + + /** + * @covers ::create + * @covers ::cleanParams + * @covers ::getProjectApi + * @test + */ + public function testCreateWithHttpClientRetrievesProjectId() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function (string $method, string $path, string $body = '') { + if ($method === 'GET') { + $this->assertSame('/projects.json', $path); + $this->assertSame('', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/json', + 'getBody' => '{"projects":[{"name":"Project Name","id":3}]}', + ] + ); + } + + if ($method === 'POST') { + $this->assertSame('/issues.xml', $path); + $this->assertXmlStringEqualsXmlString('3', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/xml', + 'getBody' => '', + ] + ); + } + + throw new \Exception(); + }); + + // Create the object under test + $api = new Issue($client); + + $xmlElement = $api->create(['project' => 'Project Name']); + + // Perform the tests + $this->assertInstanceOf(SimpleXMLElement::class, $xmlElement); + $this->assertXmlStringEqualsXmlString( + '', + $xmlElement->asXml(), + ); + } + + /** + * @covers ::create + * @covers ::cleanParams + * @covers ::getIssueCategoryApi + * @test + */ + public function testCreateWithHttpClientRetrievesIssueCategoryId() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function (string $method, string $path, string $body = '') { + if ($method === 'GET') { + $this->assertSame('/projects/3/issue_categories.json', $path); + $this->assertSame('', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/json', + 'getBody' => '{"issue_categories":[{"name":"Category Name","id":45}]}', + ] + ); + } + + if ($method === 'POST') { + $this->assertSame('/issues.xml', $path); + $this->assertXmlStringEqualsXmlString('345', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/xml', + 'getBody' => '', + ] + ); + } + + throw new \Exception(); + }); + + // Create the object under test + $api = new Issue($client); + + $xmlElement = $api->create(['project_id' => 3, 'category' => 'Category Name']); + + // Perform the tests + $this->assertInstanceOf(SimpleXMLElement::class, $xmlElement); + $this->assertXmlStringEqualsXmlString( + '', + $xmlElement->asXml(), + ); + } + + /** + * @covers ::create + * @covers ::cleanParams + * @covers ::getTrackerApi + * @test + */ + public function testCreateWithHttpClientRetrievesTrackerId() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function (string $method, string $path, string $body = '') { + if ($method === 'GET') { + $this->assertSame('/trackers.json', $path); + $this->assertSame('', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/json', + 'getBody' => '{"trackers":[{"name":"Tracker Name","id":9}]}', + ] + ); + } + + if ($method === 'POST') { + $this->assertSame('/issues.xml', $path); + $this->assertXmlStringEqualsXmlString('9', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/xml', + 'getBody' => '', + ] + ); + } + + throw new \Exception(); + }); + + // Create the object under test + $api = new Issue($client); + + $xmlElement = $api->create(['tracker' => 'Tracker Name']); + + // Perform the tests + $this->assertInstanceOf(SimpleXMLElement::class, $xmlElement); + $this->assertXmlStringEqualsXmlString( + '', + $xmlElement->asXml(), + ); + } + + /** + * @covers ::create + * @covers ::cleanParams + * @covers ::getUserApi + * @test + */ + public function testCreateWithHttpClientRetrievesUserId() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function (string $method, string $path, string $body = '') { + if ($method === 'GET') { + $this->assertSame('/users.json', $path); + $this->assertSame('', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/json', + 'getBody' => '{"users":[{"login":"Author Name","id":5},{"login":"Assigned to User Name","id":6}]}', + ] + ); + } + + if ($method === 'POST') { + $this->assertSame('/issues.xml', $path); + $this->assertXmlStringEqualsXmlString('65', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/xml', + 'getBody' => '', + ] + ); + } + + throw new \Exception(); + }); + + // Create the object under test + $api = new Issue($client); + + $xmlElement = $api->create(['assigned_to' => 'Assigned to User Name', 'author' => 'Author Name']); + + // Perform the tests + $this->assertInstanceOf(SimpleXMLElement::class, $xmlElement); + $this->assertXmlStringEqualsXmlString( + '', + $xmlElement->asXml(), + ); + } + /** * Test create() and buildXML(). * @@ -624,7 +900,7 @@ public function testUpdateCleansParameters() ->willReturn('cleanedValue'); $client = $this->createMock(Client::class); - $client->expects($this->exactly(6)) + $client->expects($this->exactly(5)) ->method('getApi') ->willReturnMap( [ @@ -670,13 +946,13 @@ public function testUpdateCleansParameters() * @covers ::setIssueStatus * @test */ - public function testSetIssueStatus() + public function testSetIssueStatusWithClient() { // Test values $response = 'API Response'; // Create the used mock objects - $issueStatusApi = $this->createMock('Redmine\Api\Project'); + $issueStatusApi = $this->createMock('Redmine\Api\IssueStatus'); $issueStatusApi->expects($this->once()) ->method('getIdByName') ->willReturn(123); @@ -710,6 +986,60 @@ public function testSetIssueStatus() $this->assertSame($response, $api->setIssueStatus(5, 'Status Name')); } + /** + * Test setIssueStatus(). + * + * @covers ::setIssueStatus + * @test + */ + public function testSetIssueStatusWithHttpClient() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function (string $method, string $path, string $body = '') { + if ($method === 'GET') { + $this->assertSame('/issue_statuses.json', $path); + $this->assertSame('', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/json', + 'getBody' => '{"issue_statuses":[{"name":"Status Name","id":123}]}', + ] + ); + } + + if ($method === 'PUT') { + $this->assertSame('/issues/5.xml', $path); + $this->assertXmlStringEqualsXmlString('5123', $body); + + return $this->createConfiguredMock( + Response::class, + [ + 'getContentType' => 'application/xml', + 'getBody' => '', + ] + ); + } + + throw new \Exception(); + }); + + // Create the object under test + $api = new Issue($client); + + $xmlElement = $api->setIssueStatus(5, 'Status Name'); + + // Perform the tests + $this->assertInstanceOf(SimpleXMLElement::class, $xmlElement); + $this->assertXmlStringEqualsXmlString( + '', + $xmlElement->asXml(), + ); + } + /** * Test addNoteToIssue(). * diff --git a/tests/Unit/Api/MembershipTest.php b/tests/Unit/Api/MembershipTest.php index f9658864..f8522488 100644 --- a/tests/Unit/Api/MembershipTest.php +++ b/tests/Unit/Api/MembershipTest.php @@ -180,7 +180,7 @@ public function testRemoveMemberCallsDelete() ->method('requestDelete') ->with($this->stringContains('/memberships/5.xml')) ->willReturn(true); - $client->expects($this->once()) + $client->expects($this->exactly(2)) ->method('getLastResponseContentType') ->willReturn('application/json'); $matcher = $this->exactly(2); diff --git a/tests/Unit/Api/ProjectTest.php b/tests/Unit/Api/ProjectTest.php index 1d177306..de28a6c6 100644 --- a/tests/Unit/Api/ProjectTest.php +++ b/tests/Unit/Api/ProjectTest.php @@ -122,7 +122,6 @@ public function testAllReturnsClientGetResponseWithParameters() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -158,7 +157,6 @@ public function testShowReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ diff --git a/tests/Unit/Api/RoleTest.php b/tests/Unit/Api/RoleTest.php index 38022ceb..381042fe 100644 --- a/tests/Unit/Api/RoleTest.php +++ b/tests/Unit/Api/RoleTest.php @@ -232,7 +232,6 @@ public function testListingCallsGetEveryTimeWithForceUpdate() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ diff --git a/tests/Unit/Api/TimeEntryTest.php b/tests/Unit/Api/TimeEntryTest.php index abf7c33a..8b01c32a 100644 --- a/tests/Unit/Api/TimeEntryTest.php +++ b/tests/Unit/Api/TimeEntryTest.php @@ -126,7 +126,6 @@ public function testAllReturnsClientGetResponseWithParameters() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ diff --git a/tests/Unit/Api/UserTest.php b/tests/Unit/Api/UserTest.php index 98b65d84..33d0d767 100644 --- a/tests/Unit/Api/UserTest.php +++ b/tests/Unit/Api/UserTest.php @@ -198,7 +198,6 @@ public function testAllReturnsClientGetResponseWithParameters() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -236,7 +235,6 @@ public function testShowReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ diff --git a/tests/Unit/Api/VersionTest.php b/tests/Unit/Api/VersionTest.php index e05bc9d2..4f4ffa1f 100644 --- a/tests/Unit/Api/VersionTest.php +++ b/tests/Unit/Api/VersionTest.php @@ -125,7 +125,6 @@ public function testAllReturnsClientGetResponseWithParameters() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -158,7 +157,6 @@ public function testShowWithNumericIdReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ diff --git a/tests/Unit/Api/WikiTest.php b/tests/Unit/Api/WikiTest.php index 65b4bd95..2ac36674 100644 --- a/tests/Unit/Api/WikiTest.php +++ b/tests/Unit/Api/WikiTest.php @@ -123,7 +123,6 @@ public function testAllReturnsClientGetResponseWithParameters() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -152,7 +151,6 @@ public function testShowWithNumericIdsReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -181,7 +179,6 @@ public function testShowWithIdentifierReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */ @@ -210,7 +207,6 @@ public function testShowWithNumericIdsAndVersionReturnsClientGetResponse() /** * Test show(). * - * @covers ::get * @covers ::show * @test */