diff --git a/src/Clients/BaseMockClient.php b/src/Clients/BaseMockClient.php index 168f3206..e9eeb170 100644 --- a/src/Clients/BaseMockClient.php +++ b/src/Clients/BaseMockClient.php @@ -3,27 +3,56 @@ namespace Sammyjo20\Saloon\Clients; use ReflectionClass; -use Illuminate\Support\Str; +use Sammyjo20\Saloon\Helpers\URLHelper; use Sammyjo20\Saloon\Http\MockResponse; +use PHPUnit\Framework\Assert as PHPUnit; use Sammyjo20\Saloon\Http\SaloonRequest; +use Sammyjo20\Saloon\Http\SaloonResponse; use Sammyjo20\Saloon\Http\SaloonConnector; +use Sammyjo20\Saloon\Helpers\ReflectionHelper; use Sammyjo20\Saloon\Exceptions\SaloonNoMockResponseFoundException; -use Sammyjo20\Saloon\Exceptions\SaloonNoMockResponsesProvidedException; use Sammyjo20\Saloon\Exceptions\SaloonInvalidMockResponseCaptureMethodException; class BaseMockClient { + /** + * Collection of all the responses that will be sequenced. + * + * @var array + */ protected array $sequenceResponses = []; + /** + * Collection of responses used only when a connector is called. + * + * @var array + */ protected array $connectorResponses = []; + /** + * Collection of responses used only when a request is called. + * + * @var array + */ protected array $requestResponses = []; + /** + * Collection of responses that will run when the request is matched. + * + * @var array + */ protected array $urlResponses = []; /** - * @param array $responses - * @throws SaloonNoMockResponsesProvidedException + * Collection of all the recorded responses. + * + * @var array + */ + protected array $recordedResponses = []; + + /** + * @param array $mockData + * @throws SaloonInvalidMockResponseCaptureMethodException */ public function __construct(array $mockData = []) { @@ -48,6 +77,14 @@ public function addResponses(array $responses): void } } + /** + * Add a mock response to the client + * + * @param MockResponse $response + * @param string|null $captureMethod + * @return void + * @throws SaloonInvalidMockResponseCaptureMethodException + */ public function addResponse(MockResponse $response, ?string $captureMethod = null): void { if (is_null($captureMethod)) { @@ -84,6 +121,11 @@ public function addResponse(MockResponse $response, ?string $captureMethod = nul $this->urlResponses[$captureMethod] = $response; } + /** + * Get the next response in the sequence + * + * @return mixed + */ public function getNextFromSequence(): mixed { return array_shift($this->sequenceResponses); @@ -134,7 +176,7 @@ public function guessNextResponse(SaloonRequest $request): MockResponse private function guessResponseFromUrl(SaloonRequest $request): ?MockResponse { foreach ($this->urlResponses as $url => $response) { - if (! Str::is(Str::start($url, '*'), $request->getFullRequestUrl())) { + if (! URLHelper::matches($url, $request->getFullRequestUrl())) { continue; } @@ -153,4 +195,208 @@ public function isEmpty(): bool { return empty($this->sequenceResponses) && empty($this->connectorResponses) && empty($this->requestResponses) && empty($this->urlResponses); } + + /** + * Record a response. + * + * @param SaloonResponse $response + * @return void + */ + public function recordResponse(SaloonResponse $response): void + { + $this->recordedResponses[] = $response; + } + + /** + * Get all the recorded responses + * + * @return array + */ + public function getRecordedResponses(): array + { + return $this->recordedResponses; + } + + /** + * Get the last request that the mock manager sent. + * + * @return SaloonRequest|null + */ + public function getLastRequest(): ?SaloonRequest + { + return $this->getLastResponse()?->getOriginalRequest(); + } + + /** + * Get the last response that the mock manager sent. + * + * @return SaloonResponse|null + */ + public function getLastResponse(): ?SaloonResponse + { + if (empty($this->recordedResponses)) { + return null; + } + + $lastResponse = end($this->recordedResponses); + + reset($this->recordedResponses); + + return $lastResponse; + } + + /** + * Assert that a given request was sent. + * + * @param string|callable $value + * @return void + * @throws \ReflectionException + */ + public function assertSent(string|callable $value): void + { + $result = $this->checkRequestWasSent($value); + + PHPUnit::assertTrue($result, 'An expected request was not sent.'); + } + + /** + * Assert that a given request was not sent. + * + * @param string|callable $request + * @return void + * @throws \ReflectionException + */ + public function assertNotSent(string|callable $request): void + { + $result = $this->checkRequestWasNotSent($request); + + PHPUnit::assertTrue($result, 'An unexpected request was sent.'); + } + + /** + * Assert JSON data was sent + * + * @param string $request + * @param array $data + * @return void + * @throws \ReflectionException + */ + public function assertSentJson(string $request, array $data): void + { + $this->assertSent($request); + + $response = $this->findResponseByRequest($request); + + PHPUnit::assertEquals($response->json(), $data, 'Expected request data was not sent.'); + } + + /** + * Assert that nothing was sent. + * + * @return void + */ + public function assertNothingSent(): void + { + PHPUnit::assertEmpty($this->getRecordedResponses(), 'Requests were sent.'); + } + + /** + * Assert a request count has been met. + * + * @param int $count + * @return void + */ + public function assertSentCount(int $count): void + { + PHPUnit::assertCount($count, $this->getRecordedResponses()); + } + + /** + * Check if a given request was sent + * + * @param string|callable $request + * @return bool + * @throws \ReflectionException + * @throws \Sammyjo20\Saloon\Exceptions\SaloonInvalidConnectorException + */ + protected function checkRequestWasSent(string|callable $request): bool + { + $result = false; + + if (is_callable($request)) { + $result = $request($this->getLastRequest(), $this->getLastResponse()); + } + + if (is_string($request)) { + if (class_exists($request) && ReflectionHelper::isSubclassOf($request, SaloonRequest::class)) { + $result = $this->findResponseByRequest($request) instanceof SaloonResponse; + } else { + $result = $this->findResponseByRequestUrl($request) instanceof SaloonResponse; + } + } + + return $result; + } + + /** + * Check if a request has not been sent. + * + * @param string|callable $request + * @return bool + * @throws \ReflectionException + * @throws \Sammyjo20\Saloon\Exceptions\SaloonInvalidConnectorException + */ + protected function checkRequestWasNotSent(string|callable $request): bool + { + return ! $this->checkRequestWasSent($request); + } + + /** + * Assert a given request was sent. + * + * @param string $request + * @return SaloonResponse|null + */ + public function findResponseByRequest(string $request): ?SaloonResponse + { + $lastRequest = $this->getLastRequest(); + + if ($lastRequest instanceof $request) { + return $this->getLastResponse(); + } + + foreach ($this->getRecordedResponses() as $recordedResponse) { + if ($recordedResponse->getOriginalRequest() instanceof $request) { + return $recordedResponse; + } + } + + return null; + } + + /** + * Find a request that matches a given url pattern + * + * @param string $url + * @return SaloonResponse|null + * @throws \Sammyjo20\Saloon\Exceptions\SaloonInvalidConnectorException + */ + public function findResponseByRequestUrl(string $url): ?SaloonResponse + { + $lastRequest = $this->getLastRequest(); + + if ($lastRequest instanceof SaloonRequest && URLHelper::matches($url, $lastRequest->getFullRequestUrl())) { + return $this->getLastResponse(); + } + + foreach ($this->getRecordedResponses() as $recordedResponse) { + $request = $recordedResponse->getOriginalRequest(); + + if (URLHelper::matches($url, $request->getFullRequestUrl())) { + return $recordedResponse; + } + } + + return null; + } } diff --git a/src/Helpers/URLHelper.php b/src/Helpers/URLHelper.php new file mode 100644 index 00000000..a552f0ff --- /dev/null +++ b/src/Helpers/URLHelper.php @@ -0,0 +1,20 @@ +setMocked($this->isMocking()); + // If we are mocking, we should record the request and response on the mock manager, + // so we can run assertions on the responses. + + if ($this->isMocking()) { + $response->setMocked(true); + $this->mockClient->recordResponse($response); + } if (property_exists($this->connector, 'shouldGuessStatusFromBody') || property_exists($this->request, 'shouldGuessStatusFromBody')) { $response->guessesStatusFromBody(); diff --git a/src/Traits/CollectsConfig.php b/src/Traits/CollectsConfig.php index 5462cf25..6b0dbda5 100644 --- a/src/Traits/CollectsConfig.php +++ b/src/Traits/CollectsConfig.php @@ -77,12 +77,11 @@ public function addConfig(string $item, $value): self /** * Get all headers or filter with a key. - * Todo: Throw an error if it doesn't exist. * * @param string|null $key * @return array */ - public function getConfig(string $key = null): array + public function getConfig(string $key = null): mixed { if ($this->includeDefaultConfig === true) { $configBag = array_merge($this->defaultConfig(), $this->customConfig); diff --git a/src/Traits/CollectsData.php b/src/Traits/CollectsData.php index 549f3252..dd4566a2 100644 --- a/src/Traits/CollectsData.php +++ b/src/Traits/CollectsData.php @@ -108,17 +108,6 @@ public function getData(string $key = null): mixed return $dataBag; } - /** - * Get an individual data - * - * @param string $key - * @return string - */ - public function getDataByKey(string $key): string - { - return $this->getData($key); - } - /** * Should we ignore the default data when calling `->getData()`? * diff --git a/src/Traits/CollectsHeaders.php b/src/Traits/CollectsHeaders.php index ceefd51d..2647f5a1 100644 --- a/src/Traits/CollectsHeaders.php +++ b/src/Traits/CollectsHeaders.php @@ -76,7 +76,6 @@ public function addHeader(string $header, $value): self /** * Get all headers or filter with a key. - * Todo: Throw an error if it doesn't exist. * * @param string|null $key * @return array diff --git a/tests/Unit/LaravelManagerTest.php b/tests/Unit/LaravelManagerTest.php new file mode 100644 index 00000000..82275d92 --- /dev/null +++ b/tests/Unit/LaravelManagerTest.php @@ -0,0 +1,25 @@ +setIsMocking(true); + + expect($laravelManager)->isMocking()->toBeTrue(); + + $laravelManager->setIsMocking(false); + + expect($laravelManager)->isMocking()->toBeFalse(); +}); + +test('the manager can have a mock client assigned to it ', function () { + $laravelManager = new LaravelManager(); + $mockClient = new MockClient(); + + $laravelManager->setMockClient($mockClient); + + expect($laravelManager)->getMockClient()->toBe($mockClient); +}); diff --git a/tests/Unit/MockClientAssertionsTest.php b/tests/Unit/MockClientAssertionsTest.php new file mode 100644 index 00000000..b3c753e2 --- /dev/null +++ b/tests/Unit/MockClientAssertionsTest.php @@ -0,0 +1,133 @@ + new MockResponse(['name' => 'Sam'], 200), + ]); + + (new UserRequest())->send($mockClient); + + $mockClient->assertSent(UserRequest::class); +}); + +test('that assertSent works with a closure', function () { + $mockClient = new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500), + ]); + + $originalRequest = new UserRequest(); + $originalResponse = $originalRequest->send($mockClient); + + $mockClient->assertSent(function ($request, $response) use ($originalRequest, $originalResponse) { + expect($request)->toBeInstanceOf(SaloonRequest::class); + expect($response)->toBeInstanceOf(SaloonResponse::class); + + expect($request)->toBe($originalRequest); + expect($response)->toBe($originalResponse); + + return true; + }); + + $newRequest = new ErrorRequest(); + $newResponse = $newRequest->send($mockClient); + + $mockClient->assertSent(function ($request, $response) use ($newRequest, $newResponse) { + expect($request)->toBeInstanceOf(SaloonRequest::class); + expect($response)->toBeInstanceOf(SaloonResponse::class); + + expect($request)->toBe($newRequest); + expect($response)->toBe($newResponse); + + return true; + }); +}); + +test('that assertSent works with a url', function () { + $mockClient = new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ]); + + (new UserRequest())->send($mockClient); + + $mockClient->assertSent('samcarre.dev/*'); + $mockClient->assertSent('/user'); + $mockClient->assertSent('api/user'); +}); + +test('that assertNotSent works with a request', function () { + $mockClient = new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500), + ]); + + (new ErrorRequest())->send($mockClient); + + $mockClient->assertNotSent(UserRequest::class); +}); + +test('that assertNotSent works with a closure', function () { + $mockClient = new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500), + ]); + + $originalRequest = new ErrorRequest(); + $originalResponse = $originalRequest->send($mockClient); + + $mockClient->assertNotSent(function ($request) { + return $request instanceof UserRequest; + }); +}); + +test('that assertNotSent works with a url', function () { + $mockClient = new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ]); + + (new UserRequest())->send($mockClient); + + $mockClient->assertNotSent('google.com/*'); + $mockClient->assertNotSent('/error'); +}); + +test('that assertSentJson works properly', function () { + $mockClient = new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ]); + + (new UserRequest())->send($mockClient); + + $mockClient->assertSentJson(UserRequest::class, [ + 'name' => 'Sam', + ]); +}); + +test('test assertNothingSent works properly', function () { + $mockClient = new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ]); + + $mockClient->assertNothingSent(); +}); + +test('test assertSentCount works properly', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam'], 200), + new MockResponse(['name' => 'Taylor'], 200), + new MockResponse(['name' => 'Marcel'], 200), + ]); + + (new UserRequest())->send($mockClient); + (new UserRequest())->send($mockClient); + (new UserRequest())->send($mockClient); + + $mockClient->assertSentCount(3); +}); diff --git a/tests/Unit/MockClientTest.php b/tests/Unit/MockClientTest.php index fca134b5..a395c65c 100644 --- a/tests/Unit/MockClientTest.php +++ b/tests/Unit/MockClientTest.php @@ -116,3 +116,95 @@ expect($mockClient->guessNextResponse($requestC))->toEqual($responseC); }); + +test('you can get an array of the recorded requests', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam'], 200), + new MockResponse(['name' => 'Taylor'], 200), + new MockResponse(['name' => 'Marcel'], 200), + ]); + + $responseA = (new UserRequest())->send($mockClient); + $responseB = (new UserRequest())->send($mockClient); + $responseC = (new UserRequest())->send($mockClient); + + $responses = $mockClient->getRecordedResponses(); + + expect($responses)->toEqual([ + $responseA, + $responseB, + $responseC, + ]); +}); + +test('you can get the last recorded request', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam'], 200), + new MockResponse(['name' => 'Taylor'], 200), + new MockResponse(['name' => 'Marcel'], 200), + ]); + + $responseA = (new UserRequest())->send($mockClient); + $responseB = (new UserRequest())->send($mockClient); + $responseC = (new UserRequest())->send($mockClient); + + $lastResponse = $mockClient->getLastResponse(); + + expect($lastResponse)->toBe($responseC); +}); + +test('if there are no recorded responses the getLastResponse will return null', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam'], 200), + ]); + + expect($mockClient)->getLastResponse()->toBeNull(); +}); + +test('if there are no recorded responses the getLastRequest will return null', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam'], 200), + ]); + + expect($mockClient)->getLastRequest()->toBeNull(); +}); + +test('if the response is not the last response it will use the loop to find it', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam'], 200), + new MockResponse(['error' => 'Server Error'], 500), + ]); + + $responseA = (new ErrorRequest())->send($mockClient); + $responseB = (new UserRequest())->send($mockClient); + + expect($mockClient)->getLastResponse()->toBe($responseB); + + // Uses last response + + expect($mockClient)->findResponseByRequest(UserRequest::class)->toBe($responseB); + + // Does not use the last response + + expect($mockClient)->findResponseByRequest(ErrorRequest::class)->toBe($responseA); +}); + +test('it will find the response by url if it is not the last response', function () { + $mockClient = new MockClient([ + '/user' => new MockResponse(['name' => 'Sam'], 200), + '/error' => new MockResponse(['error' => 'Server Error'], 500), + ]); + + $responseA = (new ErrorRequest())->send($mockClient); + $responseB = (new UserRequest())->send($mockClient); + + expect($mockClient)->getLastResponse()->toBe($responseB); + + // Uses last response + + expect($mockClient)->findResponseByRequestUrl('/user')->toBe($responseB); + + // Does not use the last response + + expect($mockClient)->findResponseByRequestUrl('/error')->toBe($responseA); +}); diff --git a/tests/Unit/SaloonResponseTest.php b/tests/Unit/SaloonResponseTest.php index ed759579..3abbe8e4 100644 --- a/tests/Unit/SaloonResponseTest.php +++ b/tests/Unit/SaloonResponseTest.php @@ -1,5 +1,7 @@ throw(); }); +test('it wont throw an exception if the request did not fail', function () { + $mockClient = new MockClient([ + new MockResponse([], 200), + ]); + + $response = (new UserRequest())->send($mockClient); + + expect($response)->throw()->toBe($response); +}); + test('to exception will return a saloon request exception', function () { $mockClient = new MockClient([ new MockResponse([], 500), @@ -52,6 +64,17 @@ expect($exception)->toBeInstanceOf(SaloonRequestException::class); }); +test('to exception wont return anything if the request did not fail', function () { + $mockClient = new MockClient([ + new MockResponse([], 200), + ]); + + $response = (new UserRequest())->send($mockClient); + $exception = $response->toException(); + + expect($exception)->toBeNull(); +}); + test('the onError method will run a custom closure', function () { $mockClient = new MockClient([ new MockResponse([], 500), @@ -66,3 +89,100 @@ expect($count)->toBe(1); }); + +test('the object method will return an object', function () { + $data = ['name' => 'Sam', 'work' => 'Codepotato']; + + $mockClient = new MockClient([ + new MockResponse($data, 500), + ]); + + $response = (new UserRequest())->send($mockClient); + + $dataAsObject = (object)$data; + + expect($response)->object()->toEqual($dataAsObject); +}); + +test('the collect method will return a collection', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam', 'work' => 'Codepotato'], 500), + ]); + + $response = (new UserRequest())->send($mockClient); + $collection = $response->collect(); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection)->toHaveCount(2); + expect($collection['name'])->toEqual('Sam'); + expect($collection['work'])->toEqual('Codepotato'); + + expect($response->collect('name'))->toArray()->toEqual(['Sam']); + expect($response->collect('age'))->toBeEmpty(); +}); + +test('the toGuzzleResponse and toPsrResponse methods will return a guzzle response', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam', 'work' => 'Codepotato'], 500), + ]); + + $response = (new UserRequest())->send($mockClient); + + expect($response)->toGuzzleResponse()->toBeInstanceOf(Response::class); + expect($response)->toPsrResponse()->toBeInstanceOf(Response::class); +}); + +test('you can get an individual header from the response', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']), + ]); + + $response = (new UserRequest())->send($mockClient); + + expect($response)->header('X-Greeting')->toEqual('Howdy'); + expect($response)->header('X-Missing')->toBeEmpty(); +}); + +test('it will convert the body to string if the cast is used', function () { + $data = ['name' => 'Sam', 'work' => 'Codepotato']; + + $mockClient = new MockClient([ + new MockResponse($data, 200, ['X-Greeting' => 'Howdy']), + ]); + + $response = (new UserRequest())->send($mockClient); + + expect((string)$response)->toEqual(json_encode($data)); +}); + +test('it checks statuses correctly', function () { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']), + new MockResponse(['name' => 'Sam', 'work' => 'Codepotato'], 500, ['X-Greeting' => 'Howdy']), + new MockResponse(['name' => 'Sam', 'work' => 'Codepotato'], 302, ['X-Greeting' => 'Howdy']), + ]); + + $responseA = (new UserRequest())->send($mockClient); + + expect($responseA)->successful()->toBeTrue(); + expect($responseA)->ok()->toBeTrue(); + expect($responseA)->redirect()->toBeFalse(); + expect($responseA)->failed()->toBeFalse(); + expect($responseA)->serverError()->toBeFalse(); + + $responseB = (new UserRequest())->send($mockClient); + + expect($responseB)->successful()->toBeFalse(); + expect($responseB)->ok()->toBeFalse(); + expect($responseB)->redirect()->toBeFalse(); + expect($responseB)->failed()->toBeTrue(); + expect($responseB)->serverError()->toBeTrue(); + + $responseC = (new UserRequest())->send($mockClient); + + expect($responseC)->successful()->toBeFalse(); + expect($responseC)->ok()->toBeFalse(); + expect($responseC)->redirect()->toBeTrue(); + expect($responseC)->failed()->toBeFalse(); + expect($responseC)->serverError()->toBeFalse(); +});