From 632d615ea10e412c61d9ece5c738781a48c744ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Mon, 7 Sep 2026 08:45:37 +0200 Subject: [PATCH 01/16] Add generic HttpStatusCode dataprovider --- src/PHPUnit/DataProviders/HttpStatusCode.php | 108 ++++++++++++++ .../DataProviders/HttpStatusCodeTest.php | 133 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/PHPUnit/DataProviders/HttpStatusCode.php create mode 100644 src/PHPUnit/DataProviders/HttpStatusCodeTest.php diff --git a/src/PHPUnit/DataProviders/HttpStatusCode.php b/src/PHPUnit/DataProviders/HttpStatusCode.php new file mode 100644 index 0000000..0d0820d --- /dev/null +++ b/src/PHPUnit/DataProviders/HttpStatusCode.php @@ -0,0 +1,108 @@ +code === Response::HTTP_NOT_FOUND; + } + + /** @return iterable> */ + public static function all(): iterable + { + foreach (Response::$statusTexts as $statusCode => $message) { + yield "{$statusCode} {$message}" => [ + new self($statusCode, $message), + ]; + } + } + + /** @return iterable> */ + public static function errors(): iterable + { + foreach (Response::$statusTexts as $statusCode => $message) { + if ($statusCode < Response::HTTP_BAD_REQUEST) { + continue; + } + + yield "{$statusCode} {$message}" => [ + new self($statusCode, $message), + ]; + } + } + + /** @return iterable> */ + public static function success(): iterable + { + foreach (Response::$statusTexts as $statusCode => $message) { + if ($statusCode >= Response::HTTP_MULTIPLE_CHOICES) { + continue; + } + + yield "{$statusCode} {$message}" => [ + new self($statusCode, $message), + ]; + } + } + + /** @return iterable> */ + public static function redirection(): iterable + { + foreach (Response::$statusTexts as $statusCode => $message) { + if ($statusCode < Response::HTTP_MULTIPLE_CHOICES) { + continue; + } + + if ($statusCode >= Response::HTTP_BAD_REQUEST) { + continue; + } + + yield "{$statusCode} {$message}" => [ + new self($statusCode, $message), + ]; + } + } + + /** @return iterable> */ + public static function clientErrors(): iterable + { + foreach (Response::$statusTexts as $statusCode => $message) { + if ($statusCode < Response::HTTP_BAD_REQUEST) { + continue; + } + + if ($statusCode >= Response::HTTP_INTERNAL_SERVER_ERROR) { + continue; + } + + yield "{$statusCode} {$message}" => [ + new self($statusCode, $message), + ]; + } + } + + /** @return iterable> */ + public static function serverErrors(): iterable + { + foreach (Response::$statusTexts as $statusCode => $message) { + if ($statusCode < Response::HTTP_INTERNAL_SERVER_ERROR) { + continue; + } + + yield "{$statusCode} {$message}" => [ + new self($statusCode, $message), + ]; + } + } +} diff --git a/src/PHPUnit/DataProviders/HttpStatusCodeTest.php b/src/PHPUnit/DataProviders/HttpStatusCodeTest.php new file mode 100644 index 0000000..1993d8e --- /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}"]); + } + } +} From c7d5aecf8702d5c196c133a53cacf4f998cfb01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Mon, 7 Sep 2026 09:40:37 +0200 Subject: [PATCH 02/16] Add FakeResponse test double This is meant to replace the FakeResponse dataprovider which relies on global state. --- src/Saloon/Doubles/FakeResponse.php | 66 +++++++++++++ src/Saloon/Doubles/FakeResponseTest.php | 117 ++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/Saloon/Doubles/FakeResponse.php create mode 100644 src/Saloon/Doubles/FakeResponseTest.php diff --git a/src/Saloon/Doubles/FakeResponse.php b/src/Saloon/Doubles/FakeResponse.php new file mode 100644 index 0000000..49e6877 --- /dev/null +++ b/src/Saloon/Doubles/FakeResponse.php @@ -0,0 +1,66 @@ +statusCode, [], json_encode($this->body)), + new PendingRequest($connector, $this->request), + Mockery::mock(RequestInterface::class), + ); + } + + public function toRequestException(Connector $connector): RequestException + { + return RequestExceptionHelper::create($this->toResponse($connector)); + } +} diff --git a/src/Saloon/Doubles/FakeResponseTest.php b/src/Saloon/Doubles/FakeResponseTest.php new file mode 100644 index 0000000..86c5b19 --- /dev/null +++ b/src/Saloon/Doubles/FakeResponseTest.php @@ -0,0 +1,117 @@ +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(); + $connector = self::createStub(Connector::class); + $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(new PendingRequest($connector, $request), $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); + } +} From e4b506509944b5d4d77820f3627c3f9f31fb9d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Mon, 7 Sep 2026 13:14:48 +0200 Subject: [PATCH 03/16] Add PublicPropertiesComparator --- .../Constraint/PublicPropertiesComparator.php | 82 +++++++++ .../PublicPropertiesComparatorTest.php | 170 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 src/PHPUnit/Constraint/PublicPropertiesComparator.php create mode 100644 src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php diff --git a/src/PHPUnit/Constraint/PublicPropertiesComparator.php b/src/PHPUnit/Constraint/PublicPropertiesComparator.php new file mode 100644 index 0000000..d5610d6 --- /dev/null +++ b/src/PHPUnit/Constraint/PublicPropertiesComparator.php @@ -0,0 +1,82 @@ +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)); + assert(is_object($actual)); + + 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..4dd9020 --- /dev/null +++ b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php @@ -0,0 +1,170 @@ +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', validateArgumentCount: false)] + public function itDoesntAcceptClassesThatDontMatchGivenClasses( + string $givenClassFQN, + mixed $expected, + mixed $actual, + ): 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); + } + }; + } +} From 2f92b4e59af6c1454735a70bc41fac9d6d375f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Mon, 7 Sep 2026 15:12:14 +0200 Subject: [PATCH 04/16] Add SpyConnector --- src/Saloon/Doubles/SpyConnector.php | 44 +++++++ src/Saloon/Doubles/SpyConnectorTest.php | 158 ++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/Saloon/Doubles/SpyConnector.php create mode 100644 src/Saloon/Doubles/SpyConnectorTest.php diff --git a/src/Saloon/Doubles/SpyConnector.php b/src/Saloon/Doubles/SpyConnector.php new file mode 100644 index 0000000..9fa2b06 --- /dev/null +++ b/src/Saloon/Doubles/SpyConnector.php @@ -0,0 +1,44 @@ + */ + 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); + } +} From bb350b5aa52685d74c8755c233c5a6edda97724d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Mon, 7 Sep 2026 16:51:52 +0200 Subject: [PATCH 05/16] Ensure FakeResponse created PendingRequest through Connector --- src/Saloon/Doubles/FakeResponse.php | 3 +-- src/Saloon/Doubles/FakeResponseTest.php | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Saloon/Doubles/FakeResponse.php b/src/Saloon/Doubles/FakeResponse.php index 49e6877..0178bfd 100644 --- a/src/Saloon/Doubles/FakeResponse.php +++ b/src/Saloon/Doubles/FakeResponse.php @@ -10,7 +10,6 @@ use Saloon\Exceptions\Request\RequestException; use Saloon\Helpers\RequestExceptionHelper; use Saloon\Http\Connector; -use Saloon\Http\PendingRequest; use Saloon\Http\Request; use Saloon\Http\Response; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -54,7 +53,7 @@ public function toResponse(Connector $connector): Response { return new Response( new Psr7Response($this->statusCode, [], json_encode($this->body)), - new PendingRequest($connector, $this->request), + $connector->createPendingRequest($this->request), Mockery::mock(RequestInterface::class), ); } diff --git a/src/Saloon/Doubles/FakeResponseTest.php b/src/Saloon/Doubles/FakeResponseTest.php index 86c5b19..e603e91 100644 --- a/src/Saloon/Doubles/FakeResponseTest.php +++ b/src/Saloon/Doubles/FakeResponseTest.php @@ -77,7 +77,8 @@ public function itCanConstructAsNotFound(): void public function itCanCastToResponses(HttpStatusCode $httpStatusCode): void { $request = new FakeRequest(); - $connector = self::createStub(Connector::class); + $pendingRequest = self::createStub(PendingRequest::class); + $connector = self::createConfiguredStub(Connector::class, ['createPendingRequest' => $pendingRequest]); $body = ['Some body']; $instance = new FakeResponse($request, $body, $httpStatusCode->code); @@ -85,7 +86,7 @@ public function itCanCastToResponses(HttpStatusCode $httpStatusCode): void $this->assertSame($httpStatusCode->code, $result->status()); $this->assertSame($body, $result->json()); - $this->assertEquals(new PendingRequest($connector, $request), $result->getPendingRequest()); + $this->assertEquals($pendingRequest, $result->getPendingRequest()); } public static function toException(): iterable From 078bf1c8ec89fe1eafe4364c2f80aeb349614dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Mon, 7 Sep 2026 17:31:32 +0200 Subject: [PATCH 06/16] Enable using FakeConnector in pure unit tests without global state --- .../Constraint/StreamInterfaceComparator.php | 55 ++++++++ src/Saloon/Doubles/FakeConnector.php | 77 ++++++++++- src/Saloon/Doubles/FakeConnectorTest.php | 127 ++++++++++++++++++ .../Doubles/MissingFakeResponseForRequest.php | 20 +++ 4 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 src/PHPUnit/Constraint/StreamInterfaceComparator.php create mode 100644 src/Saloon/Doubles/FakeConnectorTest.php create mode 100644 src/Saloon/Doubles/MissingFakeResponseForRequest.php diff --git a/src/PHPUnit/Constraint/StreamInterfaceComparator.php b/src/PHPUnit/Constraint/StreamInterfaceComparator.php new file mode 100644 index 0000000..17b6233 --- /dev/null +++ b/src/PHPUnit/Constraint/StreamInterfaceComparator.php @@ -0,0 +1,55 @@ +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/Saloon/Doubles/FakeConnector.php b/src/Saloon/Doubles/FakeConnector.php index 3749864..7ede20c 100644 --- a/src/Saloon/Doubles/FakeConnector.php +++ b/src/Saloon/Doubles/FakeConnector.php @@ -4,21 +4,90 @@ namespace Craftzing\TestBench\Saloon\Doubles; +use Override; +use PHPUnit\Framework\Constraint\IsEqual; use Saloon\Http\Auth\NullAuthenticator; use Saloon\Http\Connector; -use Saloon\Traits\HasMockClient; +use Saloon\Http\Faking\MockClient; +use Saloon\Http\PendingRequest; +use Saloon\Http\Request; +use Saloon\Http\Response; +use Saloon\Http\Senders\GuzzleSender; +use Saloon\Traits\Plugins\AlwaysThrowOnErrors; final class FakeConnector extends Connector { - use HasMockClient; + use AlwaysThrowOnErrors; + + /** @var list */ + 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()->authenticate(new NullAuthenticator()); + return new self(...$this->fakeResponses)->authenticate(new NullAuthenticator()); } public function resolveBaseUrl(): string { - return 'https://fake.localhost'; + 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/FakeConnectorTest.php b/src/Saloon/Doubles/FakeConnectorTest.php new file mode 100644 index 0000000..263220f --- /dev/null +++ b/src/Saloon/Doubles/FakeConnectorTest.php @@ -0,0 +1,127 @@ +spy(); + + $this->assertEquals(new SpyConnector($instance), $result); + } + + #[Test] + public function itCanApplyNullAuthentication(): void + { + $instance = new FakeConnector(); + + $result = $instance->withAuthentication(); + + $this->assertNull($instance->getAuthenticator()); + $this->assertEquals(new NullAuthenticator(), $result->getAuthenticator()); + } + + #[Test] + public function itAlwaysUsesGuzzleSendersToAvoidSideEffectsOfGlobalState(): void + { + $instance = new FakeConnector(); + + $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 FakeConnector($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 FakeConnector(); + + $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 FakeConnector($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 FakeConnector($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 FakeConnector($fakeResponse); + $instance->middleware()->onRequest($middleware); + $instance->middleware()->onResponse($middleware); + $instance->middleware()->onFatalException($middleware); + + try { + $instance->send($request); + } catch (RequestException) { + // Regardless of whether an exception is thrown, the middleware should never be called... + } + + $middleware->assert(new WasCalled()->never()); + } +} diff --git a/src/Saloon/Doubles/MissingFakeResponseForRequest.php b/src/Saloon/Doubles/MissingFakeResponseForRequest.php new file mode 100644 index 0000000..7bd8a07 --- /dev/null +++ b/src/Saloon/Doubles/MissingFakeResponseForRequest.php @@ -0,0 +1,20 @@ + Date: Mon, 7 Sep 2026 17:35:31 +0200 Subject: [PATCH 07/16] Deprecate FakeResponse data provider --- src/Saloon/DataProviders/FakeResponse.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Saloon/DataProviders/FakeResponse.php b/src/Saloon/DataProviders/FakeResponse.php index 4f8c902..0d945f2 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\FakeConnector + * @see \Craftzing\TestBench\PHPUnit\DataProviders\HttpStatusCode + * TODO v2: Remove in favour of the new APIs + */ final readonly class FakeResponse { public function __construct( From 922b2bb7da7ddd580ed6d507cf6db857eaa2547b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 07:36:24 +0200 Subject: [PATCH 08/16] Fix kan-defect on HttpStatusCode data provider --- src/PHPUnit/DataProviders/HttpStatusCode.php | 76 ++++++-------------- 1 file changed, 22 insertions(+), 54 deletions(-) diff --git a/src/PHPUnit/DataProviders/HttpStatusCode.php b/src/PHPUnit/DataProviders/HttpStatusCode.php index 0d0820d..53a2558 100644 --- a/src/PHPUnit/DataProviders/HttpStatusCode.php +++ b/src/PHPUnit/DataProviders/HttpStatusCode.php @@ -8,6 +8,8 @@ final readonly class HttpStatusCode { + private const int MAX_CODE = 600; + public function __construct( public int $code, public string $message, @@ -19,9 +21,17 @@ public function isNotFound(): bool } /** @return iterable> */ - public static function all(): 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), ]; @@ -29,80 +39,38 @@ public static function all(): iterable } /** @return iterable> */ - public static function errors(): iterable + public static function all(): iterable { - foreach (Response::$statusTexts as $statusCode => $message) { - if ($statusCode < Response::HTTP_BAD_REQUEST) { - continue; - } + return self::generate(Response::HTTP_CONTINUE, self::MAX_CODE); + } - yield "{$statusCode} {$message}" => [ - new self($statusCode, $message), - ]; - } + /** @return iterable> */ + public static function errors(): iterable + { + return self::generate(Response::HTTP_BAD_REQUEST, self::MAX_CODE); } /** @return iterable> */ public static function success(): iterable { - foreach (Response::$statusTexts as $statusCode => $message) { - if ($statusCode >= Response::HTTP_MULTIPLE_CHOICES) { - continue; - } - - yield "{$statusCode} {$message}" => [ - new self($statusCode, $message), - ]; - } + return self::generate(Response::HTTP_CONTINUE, Response::HTTP_MULTIPLE_CHOICES); } /** @return iterable> */ public static function redirection(): iterable { - foreach (Response::$statusTexts as $statusCode => $message) { - if ($statusCode < Response::HTTP_MULTIPLE_CHOICES) { - continue; - } - - if ($statusCode >= Response::HTTP_BAD_REQUEST) { - continue; - } - - yield "{$statusCode} {$message}" => [ - new self($statusCode, $message), - ]; - } + return self::generate(Response::HTTP_MULTIPLE_CHOICES, Response::HTTP_BAD_REQUEST); } /** @return iterable> */ public static function clientErrors(): iterable { - foreach (Response::$statusTexts as $statusCode => $message) { - if ($statusCode < Response::HTTP_BAD_REQUEST) { - continue; - } - - if ($statusCode >= Response::HTTP_INTERNAL_SERVER_ERROR) { - continue; - } - - yield "{$statusCode} {$message}" => [ - new self($statusCode, $message), - ]; - } + return self::generate(Response::HTTP_BAD_REQUEST, Response::HTTP_INTERNAL_SERVER_ERROR); } /** @return iterable> */ public static function serverErrors(): iterable { - foreach (Response::$statusTexts as $statusCode => $message) { - if ($statusCode < Response::HTTP_INTERNAL_SERVER_ERROR) { - continue; - } - - yield "{$statusCode} {$message}" => [ - new self($statusCode, $message), - ]; - } + return self::generate(Response::HTTP_INTERNAL_SERVER_ERROR, self::MAX_CODE); } } From b66fcb970ef4b10978a5389be6072d791f49fcd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 13:20:06 +0200 Subject: [PATCH 09/16] Static analysis fixes --- src/Laravel/Constraint/Bus/WasHandled.php | 1 + src/PHPUnit/Constraint/PublicPropertiesComparator.php | 7 ++++--- .../Constraint/PublicPropertiesComparatorTest.php | 3 ++- src/PHPUnit/Constraint/StreamInterfaceComparator.php | 7 +++---- src/PHPUnit/DataProviders/HttpStatusCodeTest.php | 10 +++++----- src/Saloon/Doubles/FakeConnector.php | 2 +- src/Saloon/Doubles/FakeConnectorTest.php | 1 + src/Saloon/Doubles/FakeResponse.php | 7 +++---- src/Saloon/Doubles/MissingFakeResponseForRequest.php | 2 +- src/Saloon/Doubles/SpyConnector.php | 4 ++-- 10 files changed, 23 insertions(+), 21 deletions(-) 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 index d5610d6..935bcef 100644 --- a/src/PHPUnit/Constraint/PublicPropertiesComparator.php +++ b/src/PHPUnit/Constraint/PublicPropertiesComparator.php @@ -18,6 +18,7 @@ final class PublicPropertiesComparator extends Comparator { public function __construct( + /** @var class-string */ private readonly string $classFQN, ) {} @@ -33,14 +34,14 @@ public function assertEquals( bool $canonicalize = false, bool $ignoreCase = false, ): void { - assert(is_object($expected)); - assert(is_object($actual)); + 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, + $actual::class . ' is not a ' . $expected::class, ); } diff --git a/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php index 4dd9020..85b398b 100644 --- a/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php +++ b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php @@ -114,7 +114,8 @@ public static function isEqual(): iterable yield 'Equal public properties' => [ $expected = self::subject(), - $expected->public($expected->public) + $expected + ->public($expected->public) ->protected('Different Protected') ->private('Different Private') ->virtual('Different Virtual'), diff --git a/src/PHPUnit/Constraint/StreamInterfaceComparator.php b/src/PHPUnit/Constraint/StreamInterfaceComparator.php index 17b6233..b03b9c8 100644 --- a/src/PHPUnit/Constraint/StreamInterfaceComparator.php +++ b/src/PHPUnit/Constraint/StreamInterfaceComparator.php @@ -8,7 +8,6 @@ use PHPUnit\Util\Exporter; use Psr\Http\Message\StreamInterface; use SebastianBergmann\Comparator\Comparator; - use SebastianBergmann\Comparator\ComparisonFailure; use function assert; @@ -22,14 +21,14 @@ public function accepts(mixed $expected, mixed $actual): bool public function assertEquals(mixed $expected, mixed $actual, float $delta = 0.0, bool $canonicalize = false, bool $ignoreCase = false): void { - assert($expected instanceof StreamInterface); - assert($actual instanceof StreamInterface); + assert($expected instanceof StreamInterface, 'Expected value is not an instance of ' . StreamInterface::class); + assert($actual instanceof StreamInterface, 'Actual value is not an instance of ' . StreamInterface::class); if ($actual::class !== $expected::class) { throw self::comparisonFailure( $expected, $actual, - $actual::class.' is not a '.$expected::class, + $actual::class . ' is not a ' . $expected::class, ); } diff --git a/src/PHPUnit/DataProviders/HttpStatusCodeTest.php b/src/PHPUnit/DataProviders/HttpStatusCodeTest.php index 1993d8e..5d257d2 100644 --- a/src/PHPUnit/DataProviders/HttpStatusCodeTest.php +++ b/src/PHPUnit/DataProviders/HttpStatusCodeTest.php @@ -53,7 +53,7 @@ public function itCanGenerateErrorStatusCodes(): void { $expected = array_filter( Response::$statusTexts, - static fn (int $code): bool => $code >= 400, + static fn(int $code): bool => $code >= 400, ARRAY_FILTER_USE_KEY, ); @@ -67,7 +67,7 @@ public function itCanGenerateSuccessStatusCodes(): void { $expected = array_filter( Response::$statusTexts, - static fn (int $code): bool => $code < 300, + static fn(int $code): bool => $code < 300, ARRAY_FILTER_USE_KEY, ); @@ -81,7 +81,7 @@ public function itCanGenerateRedirectionStatusCodes(): void { $expected = array_filter( Response::$statusTexts, - static fn (int $code): bool => $code >= 300 && $code < 400, + static fn(int $code): bool => $code >= 300 && $code < 400, ARRAY_FILTER_USE_KEY, ); @@ -95,7 +95,7 @@ public function itCanGenerateClientErrorStatusCodes(): void { $expected = array_filter( Response::$statusTexts, - static fn (int $code): bool => $code >= 400 && $code < 500, + static fn(int $code): bool => $code >= 400 && $code < 500, ARRAY_FILTER_USE_KEY, ); @@ -109,7 +109,7 @@ public function itCanGenerateServerErrorStatusCodes(): void { $expected = array_filter( Response::$statusTexts, - static fn (int $code): bool => $code >= 500, + static fn(int $code): bool => $code >= 500, ARRAY_FILTER_USE_KEY, ); diff --git a/src/Saloon/Doubles/FakeConnector.php b/src/Saloon/Doubles/FakeConnector.php index 7ede20c..da259e8 100644 --- a/src/Saloon/Doubles/FakeConnector.php +++ b/src/Saloon/Doubles/FakeConnector.php @@ -19,7 +19,7 @@ final class FakeConnector extends Connector { use AlwaysThrowOnErrors; - /** @var list */ + /** @var array */ private array $fakeResponses; public function __construct(FakeResponse ...$fakeResponses) diff --git a/src/Saloon/Doubles/FakeConnectorTest.php b/src/Saloon/Doubles/FakeConnectorTest.php index 263220f..8bb3523 100644 --- a/src/Saloon/Doubles/FakeConnectorTest.php +++ b/src/Saloon/Doubles/FakeConnectorTest.php @@ -119,6 +119,7 @@ public function itDoesntExecuteMiddlewareToPreventSideEffectsOfGlobalState(HttpS 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... } diff --git a/src/Saloon/Doubles/FakeResponse.php b/src/Saloon/Doubles/FakeResponse.php index 0178bfd..51051c9 100644 --- a/src/Saloon/Doubles/FakeResponse.php +++ b/src/Saloon/Doubles/FakeResponse.php @@ -4,9 +4,8 @@ namespace Craftzing\TestBench\Saloon\Doubles; +use GuzzleHttp\Psr7\Request as Psr7Request; use GuzzleHttp\Psr7\Response as Psr7Response; -use Mockery; -use Psr\Http\Message\RequestInterface; use Saloon\Exceptions\Request\RequestException; use Saloon\Helpers\RequestExceptionHelper; use Saloon\Http\Connector; @@ -52,9 +51,9 @@ public static function notFound(Request $request, array $body = []): self public function toResponse(Connector $connector): Response { return new Response( - new Psr7Response($this->statusCode, [], json_encode($this->body)), + new Psr7Response($this->statusCode, [], json_encode($this->body, JSON_THROW_ON_ERROR)), $connector->createPendingRequest($this->request), - Mockery::mock(RequestInterface::class), + new Psr7Request($this->request->getMethod()->name, $this->request->resolveEndpoint()), ); } diff --git a/src/Saloon/Doubles/MissingFakeResponseForRequest.php b/src/Saloon/Doubles/MissingFakeResponseForRequest.php index 7bd8a07..5aa0e35 100644 --- a/src/Saloon/Doubles/MissingFakeResponseForRequest.php +++ b/src/Saloon/Doubles/MissingFakeResponseForRequest.php @@ -13,7 +13,7 @@ final class MissingFakeResponseForRequest extends LogicException { public function __construct(Request $request) { - $hint = 'Did you forget to register ['.PublicPropertiesComparator::class.'] as a custom PHPUnit comparator for Saloon Requests?'; + $hint = 'Did you forget to register [' . PublicPropertiesComparator::class . '] as a custom PHPUnit comparator for Saloon Requests?'; parent::__construct("\n" . Exporter::export($request) . "\n\n{$hint}"); } diff --git a/src/Saloon/Doubles/SpyConnector.php b/src/Saloon/Doubles/SpyConnector.php index 9fa2b06..3b4a76f 100644 --- a/src/Saloon/Doubles/SpyConnector.php +++ b/src/Saloon/Doubles/SpyConnector.php @@ -21,9 +21,9 @@ public function __construct( ) { $this->send = new SpyCallable(); $this->authenticator = $this->connector?->getAuthenticator(); - $this->middlewarePipeline = $this->connector?->middleware() ?: new MiddlewarePipeline(); + $this->middlewarePipeline = $this->connector?->middleware() ?? new MiddlewarePipeline(); $this->mockClient = $this->connector?->getMockClient(); - $this->sender = $this->connector?->sender() ?: $this->defaultSender(); + $this->sender = $this->connector?->sender() ?? $this->defaultSender(); } public function resolveBaseUrl(): string From 5ba9d8ad8d1a727115353160964969c32a0c1d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 13:22:40 +0200 Subject: [PATCH 10/16] Set mago to version 1.47 --- mago.toml | 1 + 1 file changed, 1 insertion(+) 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] From 72b3f49edd8bfc4692ed227c1e248023dda2c137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 13:25:03 +0200 Subject: [PATCH 11/16] Expect deprecated-class warnings --- src/Saloon/DataProviders/FakeResponse.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Saloon/DataProviders/FakeResponse.php b/src/Saloon/DataProviders/FakeResponse.php index 0d945f2..38b789d 100644 --- a/src/Saloon/DataProviders/FakeResponse.php +++ b/src/Saloon/DataProviders/FakeResponse.php @@ -33,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, @@ -46,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, @@ -54,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, @@ -62,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, From a95a9f1a8e1c0b2b76d0db05f29964606d3dadc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 13:40:13 +0200 Subject: [PATCH 12/16] Rename to FakeResponseConnector for backwards compatibility on original FakeConnector --- src/Saloon/Constraints/WasSent.php | 9 ++ src/Saloon/DataProviders/FakeResponse.php | 2 +- src/Saloon/Doubles/FakeConnector.php | 83 ++--------------- src/Saloon/Doubles/FakeResponseConnector.php | 93 +++++++++++++++++++ ...Test.php => FakeResponseConnectorTest.php} | 18 ++-- 5 files changed, 122 insertions(+), 83 deletions(-) create mode 100644 src/Saloon/Doubles/FakeResponseConnector.php rename src/Saloon/Doubles/{FakeConnectorTest.php => FakeResponseConnectorTest.php} (88%) 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 38b789d..522a518 100644 --- a/src/Saloon/DataProviders/FakeResponse.php +++ b/src/Saloon/DataProviders/FakeResponse.php @@ -17,7 +17,7 @@ /** * @deprecated since v1.3 - * @see \Craftzing\TestBench\Saloon\Doubles\FakeConnector + * @see \Craftzing\TestBench\Saloon\Doubles\FakeResponseConnector * @see \Craftzing\TestBench\PHPUnit\DataProviders\HttpStatusCode * TODO v2: Remove in favour of the new APIs */ diff --git a/src/Saloon/Doubles/FakeConnector.php b/src/Saloon/Doubles/FakeConnector.php index da259e8..2d53187 100644 --- a/src/Saloon/Doubles/FakeConnector.php +++ b/src/Saloon/Doubles/FakeConnector.php @@ -4,90 +4,27 @@ namespace Craftzing\TestBench\Saloon\Doubles; -use Override; -use PHPUnit\Framework\Constraint\IsEqual; use Saloon\Http\Auth\NullAuthenticator; use Saloon\Http\Connector; -use Saloon\Http\Faking\MockClient; -use Saloon\Http\PendingRequest; -use Saloon\Http\Request; -use Saloon\Http\Response; -use Saloon\Http\Senders\GuzzleSender; -use Saloon\Traits\Plugins\AlwaysThrowOnErrors; +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 AlwaysThrowOnErrors; - - /** @var array */ - 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); - } + use HasMockClient; public function withAuthentication(): self { - return new self(...$this->fakeResponses)->authenticate(new NullAuthenticator()); + // @mago-expect analyzer:deprecated-class + return new self()->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([]); + return 'https://fake.localhost'; } } 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/FakeConnectorTest.php b/src/Saloon/Doubles/FakeResponseConnectorTest.php similarity index 88% rename from src/Saloon/Doubles/FakeConnectorTest.php rename to src/Saloon/Doubles/FakeResponseConnectorTest.php index 8bb3523..ecd5f6c 100644 --- a/src/Saloon/Doubles/FakeConnectorTest.php +++ b/src/Saloon/Doubles/FakeResponseConnectorTest.php @@ -16,12 +16,12 @@ use Saloon\Http\Faking\MockClient; use Saloon\Http\Senders\GuzzleSender; -final class FakeConnectorTest extends TestCase +final class FakeResponseConnectorTest extends TestCase { #[Test] public function itCanBeDecoratedAsSpy(): void { - $instance = new FakeConnector(); + $instance = new FakeResponseConnector(); $result = $instance->spy(); @@ -31,7 +31,7 @@ public function itCanBeDecoratedAsSpy(): void #[Test] public function itCanApplyNullAuthentication(): void { - $instance = new FakeConnector(); + $instance = new FakeResponseConnector(); $result = $instance->withAuthentication(); @@ -42,7 +42,7 @@ public function itCanApplyNullAuthentication(): void #[Test] public function itAlwaysUsesGuzzleSendersToAvoidSideEffectsOfGlobalState(): void { - $instance = new FakeConnector(); + $instance = new FakeResponseConnector(); $result = $instance->sender(); @@ -57,7 +57,7 @@ public function itCreatesPendingRequestsThatNeverUseMockClientsToAvoidSideEffect $client = new MockClient(); $request = new FakeRequest(); $fakeResponse = new FakeResponse($request, [], $httpStatusCode->code); - $instance = new FakeConnector($fakeResponse); + $instance = new FakeResponseConnector($fakeResponse); $result = $instance->createPendingRequest($request, $client); @@ -69,7 +69,7 @@ public function itCreatesPendingRequestsThatNeverUseMockClientsToAvoidSideEffect public function itFailsWhenSendingRequestsWithoutFakeResponse(): void { $request = new FakeRequest(); - $instance = new FakeConnector(); + $instance = new FakeResponseConnector(); $this->expectExceptionObject(new MissingFakeResponseForRequest($request)); @@ -84,7 +84,7 @@ public function itCanSendRequestsWithFakeResponses(HttpStatusCode $httpStatusCod $this->registerComparator(new StreamInterfaceComparator()); $request = new FakeRequest(); $fakeResponse = new FakeResponse($request, ['Some response'], $httpStatusCode->code); - $instance = new FakeConnector($fakeResponse); + $instance = new FakeResponseConnector($fakeResponse); $result = $instance->send($request); @@ -97,7 +97,7 @@ public function itFailsWhenSendingRequestsWithFakeErrorResponses(HttpStatusCode { $request = new FakeRequest(); $fakeResponse = new FakeResponse($request, [], $httpStatusCode->code); - $instance = new FakeConnector($fakeResponse); + $instance = new FakeResponseConnector($fakeResponse); $this->expectExceptionObject($fakeResponse->toRequestException($instance)); @@ -111,7 +111,7 @@ public function itDoesntExecuteMiddlewareToPreventSideEffectsOfGlobalState(HttpS $request = new FakeRequest(); $fakeResponse = new FakeResponse($request, [], $httpStatusCode->code); $middleware = new SpyCallable(); - $instance = new FakeConnector($fakeResponse); + $instance = new FakeResponseConnector($fakeResponse); $instance->middleware()->onRequest($middleware); $instance->middleware()->onResponse($middleware); $instance->middleware()->onFatalException($middleware); From 62fec37673da10d1ee20e183b5f33fbbdc59413f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 13:46:17 +0200 Subject: [PATCH 13/16] Remove validateArgumentCount from DataProvider This is not available in PhpUnit 11.5 --- src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php index 85b398b..1348273 100644 --- a/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php +++ b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php @@ -55,7 +55,7 @@ public static function unacceptableInstances(): iterable } #[Test] - #[DataProvider('unacceptableInstances', validateArgumentCount: false)] + #[DataProvider('unacceptableInstances')] public function itDoesntAcceptClassesThatDontMatchGivenClasses( string $givenClassFQN, mixed $expected, From 24ece9424eeffd634a5e4e0d35ad7c3042eec15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 14:25:02 +0200 Subject: [PATCH 14/16] Cleanup workflow comments --- .github/workflows/static-analysis.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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..2cb5cf2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,8 +15,8 @@ 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 From 1dae85e9671712d9fa269e62bcd6bf70ae7783ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 14:27:21 +0200 Subject: [PATCH 15/16] Enable assert.exception in ci --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2cb5cf2..be3c179 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,5 +21,6 @@ jobs: 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 From 6f46fb85e7dd087ebb9d4115ce19053ea0933c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Re=CC=81mi=20Pelhate?= Date: Tue, 8 Sep 2026 15:11:37 +0200 Subject: [PATCH 16/16] Fix invalid argument count on PublicPropertiesComparatorTest --- composer.json | 2 +- src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php index 1348273..b60e918 100644 --- a/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php +++ b/src/PHPUnit/Constraint/PublicPropertiesComparatorTest.php @@ -60,6 +60,7 @@ public function itDoesntAcceptClassesThatDontMatchGivenClasses( string $givenClassFQN, mixed $expected, mixed $actual, + string $exceptionClassFQN, ): void { $instance = new PublicPropertiesComparator($givenClassFQN);