Skip to content

Commit 6ffb20b

Browse files
Add a test implementation for the PSR HTTP client interface (#3)
1 parent c4dd413 commit 6ffb20b

File tree

8 files changed

+418
-14
lines changed

8 files changed

+418
-14
lines changed

composer.json

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,24 @@
44
"description": "",
55
"license": "proprietary",
66
"require": {
7-
"ext-soap": "*",
87
"php": ">=8.3",
8+
"ext-soap": "*",
9+
"psr/http-client": "^1.0",
10+
"psr/http-message": "^2.0",
911
"psr/log": "^3.0"
1012
},
1113
"require-dev": {
1214
"eventjet/coding-standard": "^3.12",
13-
"infection/infection": "^0.27.0",
14-
"maglnet/composer-require-checker": "^4.6",
15+
"guzzlehttp/psr7": "^2.7",
16+
"infection/infection": "^0.29.14",
17+
"maglnet/composer-require-checker": "^4.16",
1518
"phpstan/extension-installer": "^1.3",
16-
"phpstan/phpstan": "^1.10",
17-
"phpstan/phpstan-phpunit": "^1.3",
18-
"phpstan/phpstan-strict-rules": "^1.5",
19-
"phpunit/phpunit": "^10.2",
20-
"psalm/plugin-phpunit": "^0.19.0",
21-
"vimeo/psalm": "^5.10"
19+
"phpstan/phpstan": "^2.1",
20+
"phpstan/phpstan-phpunit": "^2.0",
21+
"phpstan/phpstan-strict-rules": "^2.0",
22+
"phpunit/phpunit": "^12.1",
23+
"psalm/plugin-phpunit": "^0.19.5",
24+
"vimeo/psalm": "^6.11"
2225
},
2326
"config": {
2427
"sort-packages": true,

psalm.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@
1818
<plugins>
1919
<pluginClass class="Psalm\PhpUnitPlugin\Plugin"/>
2020
</plugins>
21+
<issueHandlers>
22+
<UndefinedMagicMethod>
23+
<errorLevel type="info">
24+
<referencedMethod name="Eventjet\TestDouble\TestSoapClient::sendrequest"/>
25+
</errorLevel>
26+
</UndefinedMagicMethod>
27+
</issueHandlers>
2128
</psalm>

src/TestHttpClient.php

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Eventjet\TestDouble;
6+
7+
use Override;
8+
use Psr\Http\Client\ClientInterface;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\ResponseInterface;
11+
use RuntimeException;
12+
13+
use function array_key_first;
14+
use function array_keys;
15+
use function array_splice;
16+
use function count;
17+
use function explode;
18+
use function implode;
19+
use function sprintf;
20+
21+
/**
22+
* @phpstan-type RequestMatcher callable(RequestInterface): (true | string)
23+
*/
24+
final class TestHttpClient implements ClientInterface
25+
{
26+
/** @var list<array{RequestMatcher, ResponseInterface}> */
27+
private $mapping = [];
28+
29+
/**
30+
* @param RequestMatcher ...$matchers
31+
* @return RequestMatcher
32+
*/
33+
public static function and(callable ...$matchers): callable
34+
{
35+
return static function (RequestInterface $request) use ($matchers): true|string {
36+
$results = [];
37+
$issues = false;
38+
$i = 0;
39+
foreach ($matchers as $matcher) {
40+
$result = $matcher($request);
41+
$results[$i++] = $result;
42+
if ($result === true) {
43+
continue;
44+
}
45+
$issues = true;
46+
}
47+
if (!$issues) {
48+
return true;
49+
}
50+
$matcherResults = [];
51+
foreach ($results as $index => $result) {
52+
$matcherResults[] = sprintf('%d: %s', $index, $result === true ? 'Matched' : $result);
53+
}
54+
$matcherResults = implode("\n", $matcherResults);
55+
return sprintf("Some matchers did not match:\n%s", self::indent($matcherResults));
56+
};
57+
}
58+
59+
/**
60+
* @return RequestMatcher
61+
*/
62+
public static function method(string $expected): callable
63+
{
64+
return static function (RequestInterface $request) use ($expected): true|string {
65+
$actual = $request->getMethod();
66+
return $actual === $expected ? true : sprintf('Expected method "%s", but got "%s".', $expected, $actual);
67+
};
68+
}
69+
70+
/**
71+
* @return RequestMatcher
72+
*/
73+
public static function uri(string $expected): callable
74+
{
75+
return static function (RequestInterface $request) use ($expected): true|string {
76+
$actual = (string)$request->getUri();
77+
return $actual === $expected ? true : sprintf('Expected URI "%s", but got "%s".', $expected, $actual);
78+
};
79+
}
80+
81+
/**
82+
* @return RequestMatcher
83+
*/
84+
public static function path(string $expected): callable
85+
{
86+
return static function (RequestInterface $request) use ($expected): true|string {
87+
$actual = $request->getUri()->getPath();
88+
return $actual === $expected ? true : sprintf('Expected path "%s", but got "%s".', $expected, $actual);
89+
};
90+
}
91+
92+
private static function indent(string $string): string
93+
{
94+
$lines = explode("\n", $string);
95+
foreach ($lines as &$line) {
96+
$line = sprintf(' %s', $line);
97+
}
98+
return implode("\n", $lines);
99+
}
100+
101+
private static function requestName(RequestInterface $request): string
102+
{
103+
return sprintf('%s %s', $request->getMethod(), $request->getUri());
104+
}
105+
106+
private static function noMatchersLeft(RequestInterface $request): never
107+
{
108+
throw new RuntimeException(sprintf(
109+
'Got a request for %s, but there are no matchers left.',
110+
self::requestName($request),
111+
));
112+
}
113+
114+
/**
115+
* @param list<string> $issues
116+
*/
117+
private static function noMatches(RequestInterface $request, array $issues): never
118+
{
119+
$text = sprintf('There are no matches for request %s.', self::requestName($request));
120+
$matcherTexts = [];
121+
foreach ($issues as $index => $issue) {
122+
$matcherTexts[] = sprintf("Matcher #%d:\n%s", $index, self::indent($issue));
123+
}
124+
$text .= "\n\n" . implode("\n\n", $matcherTexts);
125+
throw new RuntimeException($text);
126+
}
127+
128+
/**
129+
* @param array<int, ResponseInterface> $matches
130+
*/
131+
private static function multipleMatches(RequestInterface $request, array $matches): never
132+
{
133+
$message = sprintf('There are multiple matches for request %s: %s', self::requestName($request), implode(', ', array_keys($matches)));
134+
throw new RuntimeException($message);
135+
}
136+
137+
#[Override]
138+
public function sendRequest(RequestInterface $request): ResponseInterface
139+
{
140+
if (count($this->mapping) === 0) {
141+
self::noMatchersLeft($request);
142+
}
143+
$matches = [];
144+
$issues = [];
145+
foreach ($this->mapping as $index => [$matcher, $response]) {
146+
$result = $matcher($request);
147+
if ($result === true) {
148+
$matches[$index] = $response;
149+
} else {
150+
$issues[] = $result;
151+
}
152+
}
153+
if (count($matches) === 0) {
154+
self::noMatches($request, $issues);
155+
}
156+
if (count($matches) > 1) {
157+
self::multipleMatches($request, $matches);
158+
}
159+
$matchIndex = array_key_first($matches);
160+
array_splice($this->mapping, $matchIndex, 1);
161+
return $matches[$matchIndex];
162+
}
163+
164+
/**
165+
* @param RequestMatcher $matcher
166+
*/
167+
public function map(callable $matcher, ResponseInterface $response): void
168+
{
169+
$this->mapping[] = [$matcher, $response];
170+
}
171+
}

src/TestLogger.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Eventjet\TestDouble;
66

7+
use Override;
78
use Psr\Log\AbstractLogger;
89
use Stringable;
910
use Throwable;
@@ -144,6 +145,7 @@ private static function indent(string $string): string
144145
return implode("\n", $lines);
145146
}
146147

148+
#[Override]
147149
public function log($level, Stringable|string $message, array $context = []): void
148150
{
149151
$this->records[] = new LogRecord($level, $message, $context);

src/TestSoapClient.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
namespace Eventjet\TestDouble;
66

77
use LogicException;
8+
use Override;
89
use SoapClient;
910
use Throwable;
1011

12+
use function array_splice;
1113
use function count;
1214
use function implode;
1315
use function sprintf;
@@ -56,6 +58,7 @@ private static function formatIssues(array $issues): string
5658
/**
5759
* @param array<array-key, mixed> $args
5860
*/
61+
#[Override]
5962
public function __call(string $name, array $args): mixed
6063
{
6164
if ($this->map === []) {
@@ -84,10 +87,13 @@ public function __call(string $name, array $args): mixed
8487
$index = $matchingIndices[0];
8588
$response = $this->map[$index][1];
8689
if ($this->map[$index][2] > 1) {
87-
/** @psalm-suppress PropertyTypeCoercion False positive: It can't become less than 1 here */
90+
/**
91+
* @psalm-suppress PropertyTypeCoercion False positive: It can't become less than 1 here
92+
* @phpstan-ignore-next-line assign.propertyType False positive: It can't become less than 1 here
93+
*/
8894
$this->map[$index][2]--;
8995
} else {
90-
unset($this->map[$index]);
96+
array_splice($this->map, $index, 1);
9197
}
9298
if ($response instanceof Throwable) {
9399
throw $response;

0 commit comments

Comments
 (0)