Skip to content

Commit e3743ae

Browse files
authored
feat(http): add testing client (#1295)
1 parent 8d0d780 commit e3743ae

File tree

11 files changed

+778
-60
lines changed

11 files changed

+778
-60
lines changed

.github/workflows/isolated-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
coverage: pcov
6060

6161
- name: Install PHPUnit
62-
run: composer global require phpunit/phpunit:^11.5.17
62+
run: composer global require phpunit/phpunit:^12.2.3
6363

6464
- name: Setup problem matchers
6565
run: |

composer.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
"voku/portable-ascii": "^2.0.3"
4141
},
4242
"require-dev": {
43-
"aidan-casey/mock-client": "dev-master",
4443
"carthage-software/mago": "0.24.1",
4544
"guzzlehttp/psr7": "^2.6.1",
4645
"illuminate/view": "~11.7.0",
@@ -59,7 +58,7 @@
5958
"phpat/phpat": "^0.11.0",
6059
"phpbench/phpbench": "84.x-dev",
6160
"phpstan/phpstan": "^2.0",
62-
"phpunit/phpunit": "^11.5.17",
61+
"phpunit/phpunit": "^12.2.3",
6362
"rector/rector": "^2.0-rc2",
6463
"spatie/phpunit-snapshot-assertions": "^5.1.8",
6564
"spaze/phpstan-disallowed-calls": "^4.0",

packages/generation/composer.json

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
{
2-
"name": "tempest/generation",
3-
"description": "A component for generating PHP.",
4-
"license": "MIT",
5-
"minimum-stability": "dev",
6-
"require": {
7-
"php": "^8.4",
8-
"nette/php-generator": "^4.1.6",
9-
"nikic/php-parser": "^5.3",
10-
"tempest/support": "dev-main"
11-
},
12-
"autoload": {
13-
"psr-4": {
14-
"Tempest\\Generation\\": "src"
15-
}
16-
},
17-
"autoload-dev": {
18-
"psr-4": {
19-
"Tempest\\Generation\\Tests\\": "tests"
20-
}
21-
},
22-
"require-dev": {
23-
"spatie/phpunit-snapshot-assertions": "^5.1.8",
24-
"phpunit/phpunit": "^11.5.17"
25-
}
2+
"name": "tempest/generation",
3+
"description": "A component for generating PHP.",
4+
"license": "MIT",
5+
"minimum-stability": "dev",
6+
"require": {
7+
"php": "^8.4",
8+
"nette/php-generator": "^4.1.6",
9+
"nikic/php-parser": "^5.3",
10+
"tempest/support": "dev-main"
11+
},
12+
"autoload": {
13+
"psr-4": {
14+
"Tempest\\Generation\\": "src"
15+
}
16+
},
17+
"autoload-dev": {
18+
"psr-4": {
19+
"Tempest\\Generation\\Tests\\": "tests"
20+
}
21+
},
22+
"require-dev": {
23+
"spatie/phpunit-snapshot-assertions": "^5.1.8",
24+
"phpunit/phpunit": "^12.2.3"
25+
}
2626
}

packages/http-client/composer.json

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,30 @@
11
{
2-
"name": "tempest/http-client",
3-
"description": "A component for handle Http client requests.",
4-
"license": "MIT",
5-
"minimum-stability": "dev",
6-
"require": {
7-
"php": "^8.4",
8-
"psr/http-client": "^1.0.0",
9-
"psr/http-message": "^1.0|^2.0",
10-
"tempest/container": "dev-main",
11-
"tempest/http": "dev-main",
12-
"tempest/router": "dev-main",
13-
"psr-discovery/http-factory-implementations": "^1.4",
14-
"psr-discovery/http-client-implementations": "^1.2"
15-
},
16-
"require-dev": {
17-
"aidan-casey/mock-client": "dev-master",
18-
"guzzlehttp/psr7": "^2.6.1",
19-
"phpunit/phpunit": "^11.5.17"
20-
},
21-
"autoload": {
22-
"psr-4": {
23-
"Tempest\\HttpClient\\": "src"
24-
}
25-
},
26-
"autoload-dev": {
27-
"psr-4": {
28-
"Tempest\\HttpClient\\Tests\\": "tests"
29-
}
30-
}
2+
"name": "tempest/http-client",
3+
"description": "A component for handle Http client requests.",
4+
"license": "MIT",
5+
"minimum-stability": "dev",
6+
"require": {
7+
"php": "^8.4",
8+
"psr/http-client": "^1.0.0",
9+
"psr/http-message": "^1.0|^2.0",
10+
"tempest/container": "dev-main",
11+
"tempest/http": "dev-main",
12+
"tempest/router": "dev-main",
13+
"psr-discovery/http-factory-implementations": "^1.4",
14+
"psr-discovery/http-client-implementations": "^1.2"
15+
},
16+
"require-dev": {
17+
"guzzlehttp/psr7": "^2.6.1",
18+
"phpunit/phpunit": "^12.2.3"
19+
},
20+
"autoload": {
21+
"psr-4": {
22+
"Tempest\\HttpClient\\": "src"
23+
}
24+
},
25+
"autoload-dev": {
26+
"psr-4": {
27+
"Tempest\\HttpClient\\Tests\\": "tests"
28+
}
29+
}
3130
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\HttpClient\Testing;
6+
7+
use PHPUnit\Framework\Assert as PHPUnit;
8+
use Psr\Http\Client\ClientInterface;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\ResponseFactoryInterface;
11+
use Psr\Http\Message\ResponseInterface;
12+
use Psr\Http\Message\StreamFactoryInterface;
13+
use PsrDiscovery\Discover;
14+
use RuntimeException;
15+
16+
/**
17+
* PSR-18 compliant HTTP testing client.
18+
*/
19+
final class MockClient implements ClientInterface
20+
{
21+
private readonly ResponseFactoryInterface $responseFactory;
22+
23+
private readonly StreamFactoryInterface $streamFactory;
24+
25+
/** @var array<array-key,RequestInterface> */
26+
private array $requests = [];
27+
28+
private RequestInterface $lastRequest;
29+
30+
/**
31+
* @var array<string,ResponseInterface|ResponseBag>
32+
*/
33+
private array $fakedResponses = [];
34+
35+
/**
36+
* @var array<string, ResponseInterface|ResponseBag>
37+
*/
38+
private array $fakedWildcardResponses = [];
39+
40+
public function __construct(?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null)
41+
{
42+
$this->responseFactory = $responseFactory ?? $this->initializeResponseFactory();
43+
$this->streamFactory = $streamFactory ?? $this->initializeStreamFactory();
44+
}
45+
46+
/**
47+
* @param array<string,ResponseInterface|ResponseBag> $map
48+
*/
49+
public static function fake(array $map = []): self
50+
{
51+
return new self()->setResponses($map);
52+
}
53+
54+
/**
55+
* @param array<array-key,ResponseInterface> $responses
56+
*/
57+
public static function sequence(array $responses = []): ResponseBag
58+
{
59+
return new ResponseBag($responses);
60+
}
61+
62+
/**
63+
* @param array<array-key,ResponseInterface> $responses
64+
*/
65+
public static function random(array $responses = []): ResponseBag
66+
{
67+
return self::sequence($responses)->randomize();
68+
}
69+
70+
/**
71+
* @param null|string|array<mixed,mixed> $body
72+
* @param array<string,string> $headers
73+
*/
74+
public static function response(null|string|array $body = null, int $code = 200, array $headers = []): ResponseInterface
75+
{
76+
$client = new self();
77+
$response = $client->responseFactory->createResponse($code);
78+
$body = is_array($body) ? json_encode($body) : $body;
79+
80+
if ($body) {
81+
$stream = is_file($body)
82+
? $client->streamFactory->createStreamFromFile($body)
83+
: $client->streamFactory->createStream($body);
84+
85+
$response = $response->withBody($stream);
86+
}
87+
88+
foreach ($headers as $header => $value) {
89+
$response = $response->withHeader($header, $value);
90+
}
91+
92+
return $response;
93+
}
94+
95+
public function sendRequest(RequestInterface $request): ResponseInterface
96+
{
97+
$this->lastRequest = $request;
98+
$this->requests[] = $request;
99+
100+
if ($response = $this->resolveFakeResponse($request)) {
101+
return $response;
102+
}
103+
104+
if ($response = $this->resolveWildcardFakeResponse($request)) {
105+
return $response;
106+
}
107+
108+
return $this->responseFactory->createResponse();
109+
}
110+
111+
public function assertUri(string $uri): self
112+
{
113+
$this->assertRequestsWereMade();
114+
115+
PHPUnit::assertSame(
116+
strtolower($uri),
117+
strtolower($this->lastRequest->getUri()->__toString()),
118+
);
119+
120+
return $this;
121+
}
122+
123+
public function assertMethod(string $method): self
124+
{
125+
$this->assertRequestsWereMade();
126+
127+
PHPUnit::assertSame(
128+
strtoupper($method),
129+
strtoupper($this->lastRequest->getMethod()),
130+
);
131+
132+
return $this;
133+
}
134+
135+
public function assertHeaderEquals(string $header, mixed $value): self
136+
{
137+
$this->assertRequestsWereMade();
138+
139+
PHPUnit::assertSame(
140+
$value,
141+
$this->lastRequest->getHeaderLine($header),
142+
);
143+
144+
return $this;
145+
}
146+
147+
public function assertBodyIs(string $content): self
148+
{
149+
$this->assertRequestsWereMade();
150+
151+
PHPUnit::assertSame(
152+
$content,
153+
$this->lastRequest->getBody()->getContents(),
154+
);
155+
156+
return $this;
157+
}
158+
159+
public function assertBodyIsEmpty(): self
160+
{
161+
return $this->assertBodyIs('');
162+
}
163+
164+
public function assertBodyContains(string $content): self
165+
{
166+
$this->assertRequestsWereMade();
167+
168+
PHPUnit::assertStringContainsString(
169+
$content,
170+
$this->lastRequest->getBody()->getContents(),
171+
);
172+
173+
return $this;
174+
}
175+
176+
public function assertRequestsWereMade(?int $count = null): self
177+
{
178+
if ($count) {
179+
PHPUnit::assertCount($count, $this->requests);
180+
} else {
181+
PHPUnit::assertNotEmpty($this->requests);
182+
}
183+
184+
return $this;
185+
}
186+
187+
public function assertNoRequestsWereMade(): self
188+
{
189+
PHPUnit::assertEmpty($this->requests);
190+
191+
return $this;
192+
}
193+
194+
/**
195+
* @param array<string,ResponseInterface|ResponseBag> $responses
196+
*/
197+
private function setResponses(array $responses): self
198+
{
199+
foreach ($responses as $uri => $response) {
200+
$this->setResponse($uri, $response);
201+
}
202+
203+
return $this;
204+
}
205+
206+
private function setResponse(string $uri, ResponseInterface|ResponseBag $response): self
207+
{
208+
if (str_contains($uri, '*')) {
209+
$this->fakedWildcardResponses[$uri] = $response;
210+
} else {
211+
$this->fakedResponses[$uri] = $response;
212+
}
213+
214+
return $this;
215+
}
216+
217+
private function resolveFakeResponse(RequestInterface $request): ?ResponseInterface
218+
{
219+
foreach ($this->fakedResponses as $uri => $fakedResponse) {
220+
if (strtolower($uri) !== strtolower($request->getUri()->__toString())) {
221+
continue;
222+
}
223+
224+
return ($fakedResponse instanceof ResponseBag)
225+
? $fakedResponse->getNextResponse()
226+
: $fakedResponse;
227+
}
228+
229+
return null;
230+
}
231+
232+
private function resolveWildcardFakeResponse(RequestInterface $request): ?ResponseInterface
233+
{
234+
foreach ($this->fakedWildcardResponses as $url => $fakedWildcardResponse) {
235+
$url = str_replace('\*', '.*', preg_quote($url, '/'));
236+
237+
if (! preg_match("/{$url}/i", $request->getUri()->__toString())) {
238+
continue;
239+
}
240+
241+
return ($fakedWildcardResponse instanceof ResponseBag)
242+
? $fakedWildcardResponse->getNextResponse()
243+
: $fakedWildcardResponse;
244+
}
245+
246+
return null;
247+
}
248+
249+
private function initializeResponseFactory(): ResponseFactoryInterface
250+
{
251+
return Discover::httpResponseFactory() ?? throw new RuntimeException(
252+
'The PSR request factory cannot be null. Please ensure that it is properly initialized.',
253+
);
254+
}
255+
256+
private function initializeStreamFactory(): StreamFactoryInterface
257+
{
258+
return Discover::httpStreamFactory() ?? throw new RuntimeException(
259+
'The PSR stream factory cannot be null. Please ensure that it is properly initialized.',
260+
);
261+
}
262+
}

0 commit comments

Comments
 (0)