Skip to content

Commit c4dd413

Browse files
Add TestSoapClient (#2)
1 parent 03895dd commit c4dd413

File tree

4 files changed

+315
-1
lines changed

4 files changed

+315
-1
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "",
55
"license": "proprietary",
66
"require": {
7+
"ext-soap": "*",
78
"php": ">=8.3",
89
"psr/log": "^3.0"
910
},
@@ -16,7 +17,7 @@
1617
"phpstan/phpstan-phpunit": "^1.3",
1718
"phpstan/phpstan-strict-rules": "^1.5",
1819
"phpunit/phpunit": "^10.2",
19-
"psalm/plugin-phpunit": "^0.18.4",
20+
"psalm/plugin-phpunit": "^0.19.0",
2021
"vimeo/psalm": "^5.10"
2122
},
2223
"config": {

src/TestSoapClient.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Eventjet\TestDouble;
6+
7+
use LogicException;
8+
use SoapClient;
9+
use Throwable;
10+
11+
use function count;
12+
use function implode;
13+
use function sprintf;
14+
15+
/**
16+
* @phpstan-type Matcher callable(string $name, array<array-key, mixed> $args): (true | string)
17+
*/
18+
final class TestSoapClient extends SoapClient
19+
{
20+
/** @var list<array{Matcher, object, positive-int}> */
21+
private array $map = [];
22+
23+
/**
24+
* @return Matcher
25+
*/
26+
public static function any(): callable
27+
{
28+
return static fn(): true => true;
29+
}
30+
31+
/**
32+
* @return Matcher
33+
*/
34+
public static function argValue(string $key, mixed $value): callable
35+
{
36+
return static function (string $name, array $args) use ($key, $value): string|true {
37+
if ($args[$key] === $value) {
38+
return true;
39+
}
40+
return "Arg with key $key has not expected value";
41+
};
42+
}
43+
44+
/**
45+
* @param array<int, string> $issues
46+
*/
47+
private static function formatIssues(array $issues): string
48+
{
49+
$formatted = [];
50+
foreach ($issues as $index => $issue) {
51+
$formatted[] = sprintf("Response #%d:\n%s", $index, $issue);
52+
}
53+
return implode("\n\n", $formatted);
54+
}
55+
56+
/**
57+
* @param array<array-key, mixed> $args
58+
*/
59+
public function __call(string $name, array $args): mixed
60+
{
61+
if ($this->map === []) {
62+
throw new LogicException('No responses mapped');
63+
}
64+
$matchingIndices = [];
65+
$issues = [];
66+
foreach ($this->map as $index => [$matcher]) {
67+
$result = $matcher($name, $args);
68+
if ($result === true) {
69+
$matchingIndices[] = $index;
70+
continue;
71+
}
72+
$issues[$index] = $result;
73+
}
74+
if ($matchingIndices === []) {
75+
throw new LogicException(
76+
sprintf("No matching response found\n\n%s", self::formatIssues($issues)),
77+
);
78+
}
79+
if (count($matchingIndices) > 1) {
80+
throw new LogicException(
81+
sprintf('Expected exactly one matching response, but found %d', count($matchingIndices)),
82+
);
83+
}
84+
$index = $matchingIndices[0];
85+
$response = $this->map[$index][1];
86+
if ($this->map[$index][2] > 1) {
87+
/** @psalm-suppress PropertyTypeCoercion False positive: It can't become less than 1 here */
88+
$this->map[$index][2]--;
89+
} else {
90+
unset($this->map[$index]);
91+
}
92+
if ($response instanceof Throwable) {
93+
throw $response;
94+
}
95+
return $response;
96+
}
97+
98+
/**
99+
* @param Matcher $matcher
100+
* @param positive-int $maxMatches
101+
* @infection-ignore-all mutating $maxMatches to 0 doesn't make sense because of positive-int type
102+
*/
103+
public function map(callable $matcher, object $response, int $maxMatches = 1): void
104+
{
105+
$this->map[] = [$matcher, $response, $maxMatches];
106+
}
107+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<wsdl:definitions xmlns:tm="http://microsoft.com/wsdl/mime/textMatching/" xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/" xmlns:mime="http://schemas.xmlsoap.org/wsdl/mime/" xmlns:tns="https://webservice.isys-cee.com/GAC" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:s="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/" xmlns:http="http://schemas.xmlsoap.org/wsdl/http/" targetNamespace="https://webservice.isys-cee.com/GAC" xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">
3+
<wsdl:documentation xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">GACWS_v1.2</wsdl:documentation>
4+
<wsdl:types>
5+
<s:schema elementFormDefault="qualified" targetNamespace="https://webservice.isys-cee.com/GAC">
6+
<s:element name="SendRequest">
7+
<s:complexType>
8+
<s:sequence>
9+
<s:element minOccurs="0" maxOccurs="1" name="Input" type="tns:InputData"/>
10+
</s:sequence>
11+
</s:complexType>
12+
</s:element>
13+
<s:complexType name="InputData">
14+
<s:sequence>
15+
<s:element minOccurs="0" maxOccurs="1" name="UserName" type="s:string"/>
16+
<s:element minOccurs="0" maxOccurs="1" name="Password" type="s:string"/>
17+
<s:element minOccurs="0" maxOccurs="1" name="ProjectID" type="s:string"/>
18+
<s:element minOccurs="0" maxOccurs="1" name="RequestType" type="s:string"/>
19+
<s:element minOccurs="0" maxOccurs="1" name="XMLData" type="s:string"/>
20+
</s:sequence>
21+
</s:complexType>
22+
<s:element name="SendRequestResponse">
23+
<s:complexType>
24+
<s:sequence>
25+
<s:element minOccurs="0" maxOccurs="1" name="SendRequestResult" type="tns:OutputData"/>
26+
</s:sequence>
27+
</s:complexType>
28+
</s:element>
29+
<s:complexType name="OutputData">
30+
<s:sequence>
31+
<s:element minOccurs="1" maxOccurs="1" name="Status" type="s:int"/>
32+
<s:element minOccurs="0" maxOccurs="1" name="Error" type="s:string"/>
33+
<s:element minOccurs="0" maxOccurs="1" name="ProjectID" type="s:string"/>
34+
<s:element minOccurs="0" maxOccurs="1" name="RequestType" type="s:string"/>
35+
<s:element minOccurs="0" maxOccurs="1" name="XMLResult" type="s:string"/>
36+
</s:sequence>
37+
</s:complexType>
38+
</s:schema>
39+
</wsdl:types>
40+
<wsdl:message name="SendRequestSoapIn">
41+
<wsdl:part name="parameters" element="tns:SendRequest"/>
42+
</wsdl:message>
43+
<wsdl:message name="SendRequestSoapOut">
44+
<wsdl:part name="parameters" element="tns:SendRequestResponse"/>
45+
</wsdl:message>
46+
<wsdl:portType name="GACWSSoap">
47+
<wsdl:operation name="SendRequest">
48+
<wsdl:documentation xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">&lt;br/&gt;&lt;b&gt;Description&lt;/b&gt;&lt;br/&gt;Sending request to the Web-service&lt;br/&gt;&lt;br/&gt;&lt;b&gt;Params&lt;/b&gt;&lt;br/&gt;InputData &lt;i&gt;(XMLData must be Triple DES ecrypted (ECB) with unique secret key)&lt;/i&gt;&lt;br/&gt;&lt;br/&gt;&lt;b&gt;Returns&lt;/b&gt;&lt;br/&gt;OutputData &lt;i&gt;(XMLResult is Triple DES ecrypted (ECB))&lt;/i&gt;</wsdl:documentation>
49+
<wsdl:input message="tns:SendRequestSoapIn"/>
50+
<wsdl:output message="tns:SendRequestSoapOut"/>
51+
</wsdl:operation>
52+
</wsdl:portType>
53+
<wsdl:binding name="GACWSSoap" type="tns:GACWSSoap">
54+
<soap:binding transport="http://schemas.xmlsoap.org/soap/http"/>
55+
<wsdl:operation name="SendRequest">
56+
<soap:operation soapAction="https://webservice.isys-cee.com/GAC/SendRequest" style="document"/>
57+
<wsdl:input>
58+
<soap:body use="literal"/>
59+
</wsdl:input>
60+
<wsdl:output>
61+
<soap:body use="literal"/>
62+
</wsdl:output>
63+
</wsdl:operation>
64+
</wsdl:binding>
65+
<wsdl:binding name="GACWSSoap12" type="tns:GACWSSoap">
66+
<soap12:binding transport="http://schemas.xmlsoap.org/soap/http"/>
67+
<wsdl:operation name="SendRequest">
68+
<soap12:operation soapAction="https://webservice.isys-cee.com/GAC/SendRequest" style="document"/>
69+
<wsdl:input>
70+
<soap12:body use="literal"/>
71+
</wsdl:input>
72+
<wsdl:output>
73+
<soap12:body use="literal"/>
74+
</wsdl:output>
75+
</wsdl:operation>
76+
</wsdl:binding>
77+
<wsdl:service name="GACWS">
78+
<wsdl:documentation xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">GACWS_v1.2</wsdl:documentation>
79+
<wsdl:port name="GACWSSoap" binding="tns:GACWSSoap">
80+
<soap:address location="https://webservice.isys-cee.com/TEST/GAC/Service.asmx"/>
81+
</wsdl:port>
82+
<wsdl:port name="GACWSSoap12" binding="tns:GACWSSoap12">
83+
<soap12:address location="https://webservice.isys-cee.com/TEST/GAC/Service.asmx"/>
84+
</wsdl:port>
85+
</wsdl:service>
86+
</wsdl:definitions>

tests/unit/TestSoapClientTest.php

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Eventjet\Test\Unit\TestDouble;
6+
7+
use Eventjet\TestDouble\TestSoapClient;
8+
use LogicException;
9+
use PHPUnit\Framework\TestCase;
10+
use RuntimeException;
11+
use stdClass;
12+
use Throwable;
13+
14+
/**
15+
* @phpstan-import-type Matcher from TestSoapClient
16+
*/
17+
final class TestSoapClientTest extends TestCase
18+
{
19+
private TestSoapClient $soapClient;
20+
21+
/**
22+
* @return iterable<string, array{callable(TestSoapClient): void, class-string<Throwable>, string}>
23+
*/
24+
public static function throwsExceptionCases(): iterable
25+
{
26+
yield 'No responses mapped' => [
27+
static function (): void {},
28+
LogicException::class,
29+
'No responses mapped',
30+
];
31+
yield 'No matching response' => [
32+
static function (TestSoapClient $client): void {
33+
$client->map(static fn(): string => 'not valid', new stdClass());
34+
},
35+
LogicException::class,
36+
"No matching response found\n\nResponse #0:\nnot valid",
37+
];
38+
yield 'Expected exactly one matching' => [
39+
static function (TestSoapClient $client): void {
40+
$client->map(TestSoapClient::any(), new stdClass());
41+
$client->map(TestSoapClient::any(), new stdClass());
42+
},
43+
LogicException::class,
44+
'Expected exactly one matching response, but found 2',
45+
];
46+
yield 'Throws response if Throwable' => [
47+
static function (TestSoapClient $client): void {
48+
$client->map(TestSoapClient::any(), new RuntimeException('Test'));
49+
},
50+
RuntimeException::class,
51+
'Test',
52+
];
53+
}
54+
55+
public function testHappyPath(): void
56+
{
57+
$responseA = new stdClass();
58+
$responseB = new stdClass();
59+
$this->soapClient->map(TestSoapClient::argValue('foo', 'bar'), $responseA, 2);
60+
$this->soapClient->map(TestSoapClient::argValue('foo', 'baz'), $responseB);
61+
62+
/** @phpstan-ignore argument.unknown */
63+
$clientResponseA1 = $this->soapClient->sendRequest(foo: 'bar');
64+
/** @phpstan-ignore argument.unknown */
65+
$clientResponseA2 = $this->soapClient->sendRequest(foo: 'bar');
66+
/** @phpstan-ignore argument.unknown */
67+
$clientResponseB = $this->soapClient->sendRequest(foo: 'baz');
68+
69+
self::assertSame($responseA, $clientResponseA1);
70+
self::assertSame($responseA, $clientResponseA2);
71+
self::assertSame($responseB, $clientResponseB);
72+
}
73+
74+
public function testThrowsIfMaxMatchesIsReached(): void
75+
{
76+
$response = new stdClass();
77+
$this->soapClient->map(TestSoapClient::any(), $response, 2);
78+
$this->soapClient->sendRequest();
79+
$this->soapClient->sendRequest();
80+
81+
$this->expectException(LogicException::class);
82+
83+
$this->soapClient->sendRequest();
84+
}
85+
86+
public function testHasOneMaxMatchByDefault(): void
87+
{
88+
$response = new stdClass();
89+
$this->soapClient->map(TestSoapClient::any(), $response);
90+
$this->soapClient->sendRequest();
91+
92+
$this->expectException(LogicException::class);
93+
94+
$this->soapClient->sendRequest();
95+
}
96+
97+
/**
98+
* @param class-string<Throwable> $expectedExceptionClass
99+
* @dataProvider throwsExceptionCases
100+
*/
101+
public function testThrowsException(
102+
callable $prepare,
103+
string $expectedExceptionClass,
104+
string $expectedExceptionMessage,
105+
): void {
106+
($prepare)($this->soapClient);
107+
108+
$this->expectException($expectedExceptionClass);
109+
$this->expectExceptionMessage($expectedExceptionMessage);
110+
111+
$this->soapClient->sendRequest();
112+
}
113+
114+
protected function setUp(): void
115+
{
116+
parent::setUp();
117+
118+
$this->soapClient = new TestSoapClient(__DIR__ . '/Fixtures/Service.asmx.xml');
119+
}
120+
}

0 commit comments

Comments
 (0)