diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 12bb8e6..6ad55f9 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -8,8 +8,8 @@ jobs: mago: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # https://github.com/actions/checkout/releases/tag/v6.0.2 - - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # https://github.com/shivammathur/setup-php/releases/tag/2.37.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: 8.5 extensions: -pdo_mysql, -mysqli diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95593d1..be3c179 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,11 +15,12 @@ jobs: dependency-version: [prefer-lowest, prefer-stable] name: PHP ${{ matrix.php }} - ${{ matrix.dependency-version }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # https://github.com/actions/checkout/releases/tag/v6.0.2 - - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # https://github.com/shivammathur/setup-php/releases/tag/2.37.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ matrix.php }} coverage: pcov extensions: -pdo_mysql, -mysqli + ini-values: zend.assertions=1, assert.exception=1 - run: composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction - run: composer test:coverage diff --git a/composer.json b/composer.json index 3646ec5..32ed474 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,7 @@ "prefer-stable": true, "scripts": { "mago": "vendor/bin/mago --colors=always", - "phpunit": "vendor/bin/phpunit --colors=always --display-phpunit-deprecations --display-deprecations", + "phpunit": "vendor/bin/phpunit --colors=always --display-all-issues", "lint:check": "@composer mago -- lint --minimum-fail-level=warning --reporting-format=rich", "lint:fix": "@composer mago -- lint --fix", "format:check": "@composer mago -- format --dry-run", diff --git a/mago.toml b/mago.toml index d3f2521..00d2762 100644 --- a/mago.toml +++ b/mago.toml @@ -1,4 +1,5 @@ extends = "mago.dist.toml" +version = "1.47" php-version = "8.4.0" [source] diff --git a/src/Laravel/Constraint/Bus/WasHandled.php b/src/Laravel/Constraint/Bus/WasHandled.php index 1fbf25b..a409dea 100644 --- a/src/Laravel/Constraint/Bus/WasHandled.php +++ b/src/Laravel/Constraint/Bus/WasHandled.php @@ -73,6 +73,7 @@ protected function matches(mixed $other): bool self::class . ' can only be evaluated for strings or command instances, got ' . gettype($other) . '.', ), }; + /** @var object|class-string $other */ $command = match ($other) { $commandName => new ReflectionClass($other)->newInstanceWithoutConstructor(), default => $other, diff --git a/src/PHPUnit/Constraint/PublicPropertiesComparator.php b/src/PHPUnit/Constraint/PublicPropertiesComparator.php new file mode 100644 index 0000000..935bcef --- /dev/null +++ b/src/PHPUnit/Constraint/PublicPropertiesComparator.php @@ -0,0 +1,83 @@ +classFQN) && is_a($actual, $this->classFQN); + } + + public function assertEquals( + mixed $expected, + mixed $actual, + float $delta = 0.0, + bool $canonicalize = false, + bool $ignoreCase = false, + ): void { + assert(is_object($expected), description: 'Expected value is not an object'); + assert(is_object($actual), description: 'Actual value is not an object'); + + if ($actual::class !== $expected::class) { + throw self::comparisonFailure( + $expected, + $actual, + $actual::class . ' is not a ' . $expected::class, + ); + } + + $isEqual = new IsEqual($this->comparableProperties($expected)); + + if ($isEqual->evaluate($this->comparableProperties($actual), returnResult: true)) { + return; + } + + throw self::comparisonFailure($expected, $actual, 'Class does not have expected property values'); + } + + /** @return array */ + private function comparableProperties(object $subject): array + { + $properties = []; + + foreach (new ReflectionClass($subject)->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isVirtual()) { + continue; + } + + $properties[$property->getName()] = $property->getValue($subject); + } + + return $properties; + } + + private static function comparisonFailure(object $expected, object $actual, string $message): ComparisonFailure + { + return new ComparisonFailure( + $expected, + $actual, + Exporter::export($expected), + Exporter::export($actual), + $message, + ); + } +} diff --git a/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php new file mode 100644 index 0000000..b60e918 --- /dev/null +++ b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php @@ -0,0 +1,172 @@ +assertInstanceOf(Comparator::class, $instance); + } + + public static function unacceptableInstances(): iterable + { + yield 'Expected instance is not an object' => [ + stdClass::class, + [], + new stdClass(), + AssertionError::class, + ]; + + yield 'Actual instance is not an object' => [ + stdClass::class, + new stdClass(), + [], + AssertionError::class, + ]; + + yield 'Expected instance does not match given class' => [ + stdClass::class, + new DateTimeImmutable(), + new stdClass(), + ComparisonFailure::class, + ]; + + yield 'Actual instance does not match given class' => [ + stdClass::class, + new stdClass(), + new DateTimeImmutable(), + ComparisonFailure::class, + ]; + } + + #[Test] + #[DataProvider('unacceptableInstances')] + public function itDoesntAcceptClassesThatDontMatchGivenClasses( + string $givenClassFQN, + mixed $expected, + mixed $actual, + string $exceptionClassFQN, + ): void { + $instance = new PublicPropertiesComparator($givenClassFQN); + + $result = $instance->accepts($expected, $actual); + + $this->assertFalse($result); + } + + #[Test] + public function itAcceptsClassesThatMatchGivenClasses(): void + { + $instance = new PublicPropertiesComparator(stdClass::class); + + $result = $instance->accepts(new stdClass(), new stdClass()); + + $this->assertTrue($result); + } + + #[Test] + #[DataProvider('unacceptableInstances')] + public function itFailsWhenComparingUnacceptableInstances( + string $givenClassFQN, + mixed $expected, + mixed $actual, + string $exceptionClassFQN, + ): void { + $instance = new PublicPropertiesComparator($givenClassFQN); + + $this->expectException($exceptionClassFQN); + + $instance->assertEquals($expected, $actual); + } + + #[Test] + public function itFailsWhenComparingInstancesWithDifferentPublicProperties(): void + { + $expected = self::subject(); + $actual = $expected->public('Different'); + $instance = new PublicPropertiesComparator($expected::class); + + $this->expectException(ComparisonFailure::class); + + $instance->assertEquals($expected, $actual); + } + + public static function isEqual(): iterable + { + yield 'Same instances' => [ + $expected = self::subject(), + $expected, + ]; + + yield 'Equal public properties' => [ + $expected = self::subject(), + $expected + ->public($expected->public) + ->protected('Different Protected') + ->private('Different Private') + ->virtual('Different Virtual'), + ]; + } + + #[Test] + #[DataProvider('isEqual')] + public function itPassesWhenComparingInstancesWithEqualPublicProperties(object $expected, object $actual): void + { + $instance = new PublicPropertiesComparator($expected::class); + + $this->expectNotToPerformAssertions(); + + $instance->assertEquals($expected, $actual); + } + + private static function subject(): object + { + return new class() { + public string $virtual { + get => $this->privateVirtual; + } + + public function __construct( + public string $public = 'Public', + protected string $protected = 'Protected', + private string $private = 'Private', + private string $privateVirtual = 'Virtual', + ) {} + + public function public(string $value): self + { + return new self($value, $this->protected, $this->private, $this->virtual); + } + + public function protected(string $value): self + { + return new self($this->public, $value, $this->private, $this->virtual); + } + + public function private(string $value): self + { + return new self($this->public, $this->protected, $value, $this->virtual); + } + + public function virtual(string $value): self + { + return new self($this->public, $this->protected, $this->private, $value); + } + }; + } +} diff --git a/src/PHPUnit/Constraint/StreamInterfaceComparator.php b/src/PHPUnit/Constraint/StreamInterfaceComparator.php new file mode 100644 index 0000000..b03b9c8 --- /dev/null +++ b/src/PHPUnit/Constraint/StreamInterfaceComparator.php @@ -0,0 +1,54 @@ +evaluate((string) $actual, returnResult: true)) { + return; + } + + throw self::comparisonFailure($expected, $actual, 'Data streams are not equal.'); + } + + private static function comparisonFailure(object $expected, object $actual, string $message): ComparisonFailure + { + return new ComparisonFailure( + $expected, + $actual, + Exporter::export($expected), + Exporter::export($actual), + $message, + ); + } +} diff --git a/src/PHPUnit/DataProviders/HttpStatusCode.php b/src/PHPUnit/DataProviders/HttpStatusCode.php new file mode 100644 index 0000000..53a2558 --- /dev/null +++ b/src/PHPUnit/DataProviders/HttpStatusCode.php @@ -0,0 +1,76 @@ +code === Response::HTTP_NOT_FOUND; + } + + /** @return iterable> */ + private static function generate(int $includedMinCode, int $excludedMaxCode): iterable + { + foreach (Response::$statusTexts as $statusCode => $message) { + if ($statusCode < $includedMinCode) { + continue; + } + + if ($statusCode >= $excludedMaxCode) { + continue; + } + + yield "{$statusCode} {$message}" => [ + new self($statusCode, $message), + ]; + } + } + + /** @return iterable> */ + public static function all(): iterable + { + return self::generate(Response::HTTP_CONTINUE, self::MAX_CODE); + } + + /** @return iterable> */ + public static function errors(): iterable + { + return self::generate(Response::HTTP_BAD_REQUEST, self::MAX_CODE); + } + + /** @return iterable> */ + public static function success(): iterable + { + return self::generate(Response::HTTP_CONTINUE, Response::HTTP_MULTIPLE_CHOICES); + } + + /** @return iterable> */ + public static function redirection(): iterable + { + return self::generate(Response::HTTP_MULTIPLE_CHOICES, Response::HTTP_BAD_REQUEST); + } + + /** @return iterable> */ + public static function clientErrors(): iterable + { + return self::generate(Response::HTTP_BAD_REQUEST, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + /** @return iterable> */ + public static function serverErrors(): iterable + { + return self::generate(Response::HTTP_INTERNAL_SERVER_ERROR, self::MAX_CODE); + } +} diff --git a/src/PHPUnit/DataProviders/HttpStatusCodeTest.php b/src/PHPUnit/DataProviders/HttpStatusCodeTest.php new file mode 100644 index 0000000..5d257d2 --- /dev/null +++ b/src/PHPUnit/DataProviders/HttpStatusCodeTest.php @@ -0,0 +1,133 @@ + $message) { + yield "{$code} {$message}" => [ + $code, + $code === Response::HTTP_NOT_FOUND, + ]; + } + } + + #[Test] + #[DataProvider('isNotFound')] + public function itReturnsFalseWhenNotFound(int $code, bool $expected): void + { + $instance = new HttpStatusCode($code, 'Message'); + + $result = $instance->isNotFound(); + + $this->assertSame($expected, $result); + } + + #[Test] + public function itCanGenerateAllStatusCodes(): void + { + $expected = Response::$statusTexts; + + $results = iterator_to_array(HttpStatusCode::all()); + + $this->assertContainsStatusTexts($expected, $results); + } + + #[Test] + public function itCanGenerateErrorStatusCodes(): void + { + $expected = array_filter( + Response::$statusTexts, + static fn(int $code): bool => $code >= 400, + ARRAY_FILTER_USE_KEY, + ); + + $results = iterator_to_array(HttpStatusCode::errors()); + + $this->assertContainsStatusTexts($expected, $results); + } + + #[Test] + public function itCanGenerateSuccessStatusCodes(): void + { + $expected = array_filter( + Response::$statusTexts, + static fn(int $code): bool => $code < 300, + ARRAY_FILTER_USE_KEY, + ); + + $results = iterator_to_array(HttpStatusCode::success()); + + $this->assertContainsStatusTexts($expected, $results); + } + + #[Test] + public function itCanGenerateRedirectionStatusCodes(): void + { + $expected = array_filter( + Response::$statusTexts, + static fn(int $code): bool => $code >= 300 && $code < 400, + ARRAY_FILTER_USE_KEY, + ); + + $results = iterator_to_array(HttpStatusCode::redirection()); + + $this->assertContainsStatusTexts($expected, $results); + } + + #[Test] + public function itCanGenerateClientErrorStatusCodes(): void + { + $expected = array_filter( + Response::$statusTexts, + static fn(int $code): bool => $code >= 400 && $code < 500, + ARRAY_FILTER_USE_KEY, + ); + + $results = iterator_to_array(HttpStatusCode::clientErrors()); + + $this->assertContainsStatusTexts($expected, $results); + } + + #[Test] + public function itCanGenerateServerErrorStatusCodes(): void + { + $expected = array_filter( + Response::$statusTexts, + static fn(int $code): bool => $code >= 500, + ARRAY_FILTER_USE_KEY, + ); + + $results = iterator_to_array(HttpStatusCode::serverErrors()); + + $this->assertContainsStatusTexts($expected, $results); + } + + /** + * @param array $expected + * @param array> $results + */ + private function assertContainsStatusTexts(array $expected, array $results): void + { + $this->assertCount(count($expected), $results); + + foreach ($expected as $code => $message) { + $this->assertEquals([new HttpStatusCode($code, $message)], $results["{$code} {$message}"]); + } + } +} diff --git a/src/Saloon/Constraints/WasSent.php b/src/Saloon/Constraints/WasSent.php index d13af45..35f4d26 100644 --- a/src/Saloon/Constraints/WasSent.php +++ b/src/Saloon/Constraints/WasSent.php @@ -23,6 +23,11 @@ use function count; use function is_string; +/** + * @deprecated since v1.3 + * @see \Craftzing\TestBench\Saloon\Doubles\FakeResponseConnector + * TODO v2: Remove in favour of the new APIs + */ final class WasSent extends Constraint implements Quantable { use DerivesConstraintsFromObjects; @@ -44,21 +49,25 @@ public function __construct( public function times(int $count): self { + // @mago-expect analyzer:deprecated-class return new self($this->connector, $count, ...$this->objectConstraints); } public function never(): self { + // @mago-expect analyzer:deprecated-class return new self($this->connector, 0, ...$this->objectConstraints); } public function once(): self { + // @mago-expect analyzer:deprecated-class return new self($this->connector, 1, ...$this->objectConstraints); } public function withConstraints(Constraint ...$constraints): self { + // @mago-expect analyzer:deprecated-class return new self($this->connector, $this->times, ...$constraints); } diff --git a/src/Saloon/DataProviders/FakeResponse.php b/src/Saloon/DataProviders/FakeResponse.php index 4f8c902..522a518 100644 --- a/src/Saloon/DataProviders/FakeResponse.php +++ b/src/Saloon/DataProviders/FakeResponse.php @@ -15,6 +15,12 @@ use Saloon\Http\PendingRequest; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +/** + * @deprecated since v1.3 + * @see \Craftzing\TestBench\Saloon\Doubles\FakeResponseConnector + * @see \Craftzing\TestBench\PHPUnit\DataProviders\HttpStatusCode + * TODO v2: Remove in favour of the new APIs + */ final readonly class FakeResponse { public function __construct( @@ -27,11 +33,13 @@ public function __construct( */ public static function make(string|array $response, int $status = SymfonyResponse::HTTP_OK): self { + // @mago-expect analyzer:deprecated-class return new self(MockResponse::make($response, $status)); } public static function badRequest(): self { + // @mago-expect analyzer:deprecated-class return new self( MockResponse::make(['message' => 'Bad request'], SymfonyResponse::HTTP_BAD_REQUEST), ClientException::class, @@ -40,6 +48,7 @@ public static function badRequest(): self public static function forbidden(): self { + // @mago-expect analyzer:deprecated-class return new self( MockResponse::make(['message' => 'Forbidden'], SymfonyResponse::HTTP_FORBIDDEN), ForbiddenException::class, @@ -48,6 +57,7 @@ public static function forbidden(): self public static function notFound(): self { + // @mago-expect analyzer:deprecated-class return new self( MockResponse::make(['message' => 'Not found'], SymfonyResponse::HTTP_NOT_FOUND), NotFoundException::class, @@ -56,6 +66,7 @@ public static function notFound(): self public static function serverError(): self { + // @mago-expect analyzer:deprecated-class return new self( MockResponse::make(['message' => 'Server error'], SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR), InternalServerErrorException::class, diff --git a/src/Saloon/Doubles/FakeConnector.php b/src/Saloon/Doubles/FakeConnector.php index 3749864..2d53187 100644 --- a/src/Saloon/Doubles/FakeConnector.php +++ b/src/Saloon/Doubles/FakeConnector.php @@ -8,12 +8,18 @@ use Saloon\Http\Connector; use Saloon\Traits\HasMockClient; +/** + * @deprecated since v1.3 + * @see \Craftzing\TestBench\Saloon\Doubles\FakeResponseConnector + * TODO v2: Remove in favour of the new APIs + */ final class FakeConnector extends Connector { use HasMockClient; public function withAuthentication(): self { + // @mago-expect analyzer:deprecated-class return new self()->authenticate(new NullAuthenticator()); } diff --git a/src/Saloon/Doubles/FakeResponse.php b/src/Saloon/Doubles/FakeResponse.php new file mode 100644 index 0000000..51051c9 --- /dev/null +++ b/src/Saloon/Doubles/FakeResponse.php @@ -0,0 +1,64 @@ +statusCode, [], json_encode($this->body, JSON_THROW_ON_ERROR)), + $connector->createPendingRequest($this->request), + new Psr7Request($this->request->getMethod()->name, $this->request->resolveEndpoint()), + ); + } + + public function toRequestException(Connector $connector): RequestException + { + return RequestExceptionHelper::create($this->toResponse($connector)); + } +} diff --git a/src/Saloon/Doubles/FakeResponseConnector.php b/src/Saloon/Doubles/FakeResponseConnector.php new file mode 100644 index 0000000..e4a6ce0 --- /dev/null +++ b/src/Saloon/Doubles/FakeResponseConnector.php @@ -0,0 +1,93 @@ + */ + private array $fakeResponses; + + public function __construct(FakeResponse ...$fakeResponses) + { + $this->fakeResponses = $fakeResponses; + + // Override the sender to prevent Saloon from trying tp resolve it through the Laravel plugin. This + // may happen when running pure unit tests and Laravel unit tests in parallel using Paratest... + $this->sender = new GuzzleSender(); + } + + public function spy(): SpyConnector + { + return new SpyConnector($this); + } + + public function withAuthentication(): self + { + return new self(...$this->fakeResponses)->authenticate(new NullAuthenticator()); + } + + public function resolveBaseUrl(): string + { + return 'https://connector.fake'; + } + + #[Override] + public function send(Request $request, ?MockClient $mockClient = null, ?callable $handleRetry = null): Response + { + $fakeResponse = $this->fakeResponseMatchingRequest($request); + + if ($fakeResponse === null) { + throw new MissingFakeResponseForRequest($request); + } + + return $fakeResponse->toResponse($this)->throw(); + } + + private function fakeResponseMatchingRequest(Request $request): ?FakeResponse + { + foreach ($this->fakeResponses as $fakeResponse) { + if (new IsEqual($request)->evaluate($fakeResponse->request, returnResult: true)) { + return $fakeResponse; + } + } + + return null; + } + + public function createPendingRequest(Request $request, ?MockClient $mockClient = null): PendingRequest + { + // This Connector uses our own FakeResponse API which doesn't rely on mock clients, so + // we should never use mock clients when creating new PendingRequest instances... + return new class($this, $request, $mockClient) extends PendingRequest { + public function getMockClient(): ?MockClient + { + return null; + } + }; + } + + public function boot(PendingRequest $pendingRequest): void + { + // Flush the middleware pipeline for this request only to prevent global middleware + // (like event dispatchers) injected by the Laravel plugin. This may happen when + // running pure unit tests and Laravel unit tests in parallel using Paratest... + $pendingRequest->middleware()->getRequestPipeline()->setPipes([]); + $pendingRequest->middleware()->getResponsePipeline()->setPipes([]); + $pendingRequest->middleware()->getFatalPipeline()->setPipes([]); + } +} diff --git a/src/Saloon/Doubles/FakeResponseConnectorTest.php b/src/Saloon/Doubles/FakeResponseConnectorTest.php new file mode 100644 index 0000000..ecd5f6c --- /dev/null +++ b/src/Saloon/Doubles/FakeResponseConnectorTest.php @@ -0,0 +1,128 @@ +spy(); + + $this->assertEquals(new SpyConnector($instance), $result); + } + + #[Test] + public function itCanApplyNullAuthentication(): void + { + $instance = new FakeResponseConnector(); + + $result = $instance->withAuthentication(); + + $this->assertNull($instance->getAuthenticator()); + $this->assertEquals(new NullAuthenticator(), $result->getAuthenticator()); + } + + #[Test] + public function itAlwaysUsesGuzzleSendersToAvoidSideEffectsOfGlobalState(): void + { + $instance = new FakeResponseConnector(); + + $result = $instance->sender(); + + $this->assertEquals(new GuzzleSender(), $result); + } + + #[Test] + #[DataProviderExternal(HttpStatusCode::class, 'all')] + public function itCreatesPendingRequestsThatNeverUseMockClientsToAvoidSideEffectsOfGlobalState( + HttpStatusCode $httpStatusCode, + ): void { + $client = new MockClient(); + $request = new FakeRequest(); + $fakeResponse = new FakeResponse($request, [], $httpStatusCode->code); + $instance = new FakeResponseConnector($fakeResponse); + + $result = $instance->createPendingRequest($request, $client); + + $this->assertNull($result->getMockClient()); + $this->assertNull($result->withMockClient($client)->getMockClient()); + } + + #[Test] + public function itFailsWhenSendingRequestsWithoutFakeResponse(): void + { + $request = new FakeRequest(); + $instance = new FakeResponseConnector(); + + $this->expectExceptionObject(new MissingFakeResponseForRequest($request)); + + $instance->send($request); + } + + #[Test] + #[DataProviderExternal(HttpStatusCode::class, 'success')] + #[DataProviderExternal(HttpStatusCode::class, 'redirection')] + public function itCanSendRequestsWithFakeResponses(HttpStatusCode $httpStatusCode): void + { + $this->registerComparator(new StreamInterfaceComparator()); + $request = new FakeRequest(); + $fakeResponse = new FakeResponse($request, ['Some response'], $httpStatusCode->code); + $instance = new FakeResponseConnector($fakeResponse); + + $result = $instance->send($request); + + $this->assertEquals($fakeResponse->toResponse($instance), $result); + } + + #[Test] + #[DataProviderExternal(HttpStatusCode::class, 'errors')] + public function itFailsWhenSendingRequestsWithFakeErrorResponses(HttpStatusCode $httpStatusCode): void + { + $request = new FakeRequest(); + $fakeResponse = new FakeResponse($request, [], $httpStatusCode->code); + $instance = new FakeResponseConnector($fakeResponse); + + $this->expectExceptionObject($fakeResponse->toRequestException($instance)); + + $instance->send($request); + } + + #[Test] + #[DataProviderExternal(HttpStatusCode::class, 'all')] + public function itDoesntExecuteMiddlewareToPreventSideEffectsOfGlobalState(HttpStatusCode $httpStatusCode): void + { + $request = new FakeRequest(); + $fakeResponse = new FakeResponse($request, [], $httpStatusCode->code); + $middleware = new SpyCallable(); + $instance = new FakeResponseConnector($fakeResponse); + $instance->middleware()->onRequest($middleware); + $instance->middleware()->onResponse($middleware); + $instance->middleware()->onFatalException($middleware); + + try { + $instance->send($request); + } catch (RequestException) { + // @mago-expect lint:no-empty-catch-clause + // Regardless of whether an exception is thrown, the middleware should never be called... + } + + $middleware->assert(new WasCalled()->never()); + } +} diff --git a/src/Saloon/Doubles/FakeResponseTest.php b/src/Saloon/Doubles/FakeResponseTest.php new file mode 100644 index 0000000..e603e91 --- /dev/null +++ b/src/Saloon/Doubles/FakeResponseTest.php @@ -0,0 +1,118 @@ +assertEquals(new FakeResponse($request, $body, Response::HTTP_OK), $instance); + } + + #[Test] + public function itCanConstructAsCreated(): void + { + $request = new FakeRequest(); + $body = ['Some body']; + + $instance = FakeResponse::created($request, $body); + + $this->assertEquals(new FakeResponse($request, $body, Response::HTTP_CREATED), $instance); + } + + #[Test] + public function itCanConstructAsNoContent(): void + { + $request = new FakeRequest(); + + $instance = FakeResponse::noContent($request); + + $this->assertEquals(new FakeResponse($request, [], Response::HTTP_NO_CONTENT), $instance); + } + + #[Test] + public function itCanConstructAsBadRequest(): void + { + $request = new FakeRequest(); + $body = ['Some body']; + + $instance = FakeResponse::badRequest($request, $body); + + $this->assertEquals(new FakeResponse($request, $body, Response::HTTP_BAD_REQUEST), $instance); + } + + #[Test] + public function itCanConstructAsNotFound(): void + { + $request = new FakeRequest(); + $body = ['Some body']; + + $instance = FakeResponse::notFound($request, $body); + + $this->assertEquals(new FakeResponse($request, $body, Response::HTTP_NOT_FOUND), $instance); + } + + #[Test] + #[DataProviderExternal(HttpStatusCode::class, 'all')] + public function itCanCastToResponses(HttpStatusCode $httpStatusCode): void + { + $request = new FakeRequest(); + $pendingRequest = self::createStub(PendingRequest::class); + $connector = self::createConfiguredStub(Connector::class, ['createPendingRequest' => $pendingRequest]); + $body = ['Some body']; + $instance = new FakeResponse($request, $body, $httpStatusCode->code); + + $result = $instance->toResponse($connector); + + $this->assertSame($httpStatusCode->code, $result->status()); + $this->assertSame($body, $result->json()); + $this->assertEquals($pendingRequest, $result->getPendingRequest()); + } + + public static function toException(): iterable + { + foreach (HttpStatusCode::errors() as $case => [$httpStatusCode]) { + yield $case => [ + $httpStatusCode, + match (true) { + $httpStatusCode->code >= Response::HTTP_INTERNAL_SERVER_ERROR => ServerException::class, + $httpStatusCode->code >= Response::HTTP_BAD_REQUEST => ClientException::class, + default => throw new LogicException("Missing handling of {$httpStatusCode->code} errors."), + }, + ]; + } + } + + #[Test] + #[DataProvider('toException')] + public function itCanCastToExceptions(HttpStatusCode $httpStatusCode, string $expected): void + { + $request = new FakeRequest(); + $connector = self::createStub(Connector::class); + $instance = new FakeResponse($request, [], $httpStatusCode->code); + + $result = $instance->toRequestException($connector); + + $this->assertInstanceOf($expected, $result); + } +} diff --git a/src/Saloon/Doubles/MissingFakeResponseForRequest.php b/src/Saloon/Doubles/MissingFakeResponseForRequest.php new file mode 100644 index 0000000..5aa0e35 --- /dev/null +++ b/src/Saloon/Doubles/MissingFakeResponseForRequest.php @@ -0,0 +1,20 @@ + */ + public readonly SpyCallable $send; + + public function __construct( + private readonly ?Connector $connector = null, + ) { + $this->send = new SpyCallable(); + $this->authenticator = $this->connector?->getAuthenticator(); + $this->middlewarePipeline = $this->connector?->middleware() ?? new MiddlewarePipeline(); + $this->mockClient = $this->connector?->getMockClient(); + $this->sender = $this->connector?->sender() ?? $this->defaultSender(); + } + + public function resolveBaseUrl(): string + { + return 'https://connector.spy'; + } + + public function send(Request $request, ?MockClient $mockClient = null, ?callable $handleRetry = null): Response + { + $this->send->__invoke($request, $mockClient, $handleRetry); + + if ($this->connector === null) { + return parent::send($request, $mockClient, $handleRetry); + } + + return $this->connector->send($request, $mockClient, $handleRetry); + } +} diff --git a/src/Saloon/Doubles/SpyConnectorTest.php b/src/Saloon/Doubles/SpyConnectorTest.php new file mode 100644 index 0000000..c9b0f49 --- /dev/null +++ b/src/Saloon/Doubles/SpyConnectorTest.php @@ -0,0 +1,158 @@ +getAuthenticator(); + + $this->assertNull($result); + } + + #[Test] + public function itInheritsAuthenticatorFromDecoratedConnector(): void + { + $authenticator = new BasicAuthenticator('', ''); + $decoratedConnector = self::createConfiguredStub(Connector::class, ['getAuthenticator' => $authenticator]); + $instance = new SpyConnector($decoratedConnector); + + $result = $instance->getAuthenticator(); + + $this->assertSame($authenticator, $result); + } + + #[Test] + public function itCanOverwriteAuthenticatorInheritedFromDecoratedConnector(): void + { + $authenticator = new NullAuthenticator(); + $decoratedConnector = self::createConfiguredStub(Connector::class, [ + 'getAuthenticator' => new BasicAuthenticator('', ''), + ]); + $instance = new SpyConnector($decoratedConnector); + + $result = $instance->authenticate($authenticator)->getAuthenticator(); + + $this->assertSame($authenticator, $result); + } + + #[Test] + public function itCanHandleNoMiddleware(): void + { + $instance = new SpyConnector(); + + $result = $instance->middleware(); + + $this->assertEquals(new MiddlewarePipeline(), $result); + } + + #[Test] + public function itInheritsMiddlewareFromDecoratedConnector(): void + { + $middleware = new SpyCallable(); + $middlewarePipeline = new MiddlewarePipeline() + ->onRequest($middleware) + ->onResponse($middleware) + ->onFatalException($middleware); + $decoratedConnector = self::createConfiguredStub(Connector::class, [ + 'middleware' => $middlewarePipeline, + ]); + $instance = new SpyConnector($decoratedConnector); + + $result = $instance->middleware(); + + $this->assertSame($middlewarePipeline, $result); + } + + #[Test] + public function itCanOverwriteInheritedMiddlewareFromDecoratedConnector(): void + { + $middleware = new SpyCallable(); + $middlewarePipeline = new MiddlewarePipeline() + ->onRequest($middleware) + ->onResponse($middleware) + ->onFatalException($middleware); + $decoratedConnector = self::createConfiguredStub(Connector::class, ['middleware' => new MiddlewarePipeline()]); + $instance = new SpyConnector($decoratedConnector); + + $result = $instance->middleware()->merge($middlewarePipeline); + + $this->assertEquals($middlewarePipeline, $result); + } + + #[Test] + public function itCanHandleNoMockClient(): void + { + $instance = new SpyConnector(); + + $result = $instance->getMockClient(); + + $this->assertNull($result); + } + + #[Test] + public function itInheritsMockClientFromDecoratedConnector(): void + { + $client = new MockClient(); + $decoratedConnector = self::createConfiguredStub(Connector::class, ['getMockClient' => $client]); + $instance = new SpyConnector($decoratedConnector); + + $result = $instance->getMockClient(); + + $this->assertSame($client, $result); + } + + #[Test] + public function itCanOverwriteInheritedMockClientFromDecoratedConnector(): void + { + $client = new MockClient([self::createStub(MockResponse::class)]); + $decoratedConnector = self::createConfiguredStub(Connector::class, ['getMockClient' => new MockClient()]); + $instance = new SpyConnector($decoratedConnector); + + $result = $instance + ->withMockClient($client) + ->getMockClient(); + + $this->assertSame($client, $result); + } + + #[Test] + public function itCanHandleNoDefaultSender(): void + { + $instance = new SpyConnector(); + + $result = $instance->sender(); + + $this->assertEquals(Config::getDefaultSender(), $result); + } + + #[Test] + public function itInheritsSenderFromDecoratedConnector(): void + { + $sender = self::createStub(Sender::class); + $decoratedConnector = self::createConfiguredStub(Connector::class, ['sender' => $sender]); + $instance = new SpyConnector($decoratedConnector); + + $result = $instance->sender(); + + $this->assertSame($sender, $result); + } +}